updated the UI to manage document uploads down to measure level

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-20 16:38:20 +00:00
parent 785c40f2d1
commit 2536f3eb8e
5 changed files with 346 additions and 116 deletions

View file

@ -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 (
<div className="space-y-2">
{/* Required doc type buttons */}
<div className="flex flex-wrap gap-1.5">
{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 (
<button
key={docType}
type="button"
onClick={() => { onChange(docType); setShowOther(false); }}
title={alreadyUploaded ? `${label} — already uploaded` : label}
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all duration-100 ${
isSelected
? "bg-brandblue text-white border-brandblue shadow-sm"
: alreadyUploaded
? "bg-emerald-50 text-emerald-700 border-emerald-200 hover:border-emerald-400"
: "bg-white text-gray-700 border-gray-200 hover:border-brandblue/50 hover:bg-brandlightblue/10"
}`}
>
{alreadyUploaded && !isSelected && (
<svg className="h-3 w-3 text-emerald-500 shrink-0" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{label}
</button>
);
})}
{/* Other button */}
<button
type="button"
onClick={() => setShowOther((v) => !v)}
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all duration-100 ${
isOtherSelected || showOther
? "bg-gray-100 text-gray-700 border-gray-300"
: "bg-white text-gray-400 border-gray-200 hover:border-gray-400 hover:text-gray-600"
}`}
>
Other {showOther ? "▲" : "▼"}
</button>
</div>
{/* Other: dropdown for non-required types */}
{(showOther || isOtherSelected) && (
<Select
value={isOtherSelected ? value : "__unset__"}
onValueChange={(v) => { onChange(v === "__unset__" ? "" : v); }}
>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder="Select other type…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__" className="text-xs text-gray-400">Select other type</SelectItem>
{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 (
<SelectGroup key={group}>
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 px-2 py-1">
{group}
</SelectLabel>
{items.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
))}
</SelectGroup>
);
})}
</SelectContent>
</Select>
)}
{/* Show hint for selected type */}
{value && (() => {
const hint = FILE_TYPE_OPTIONS.find((o) => o.value === value)?.hint;
return hint ? <p className="text-[10px] text-blue-600 leading-snug">{hint}</p> : null;
})()}
</div>
);
}
// ── 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 (
<div className="space-y-1">
@ -293,32 +387,8 @@ function DocTypeSelect({
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__" className="text-xs text-gray-400">Select type</SelectItem>
{/* Priority group: required docs for the selected measure */}
{priorityItems.length > 0 && (
<SelectGroup>
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wide text-blue-500 px-2 py-1">
Required for this measure
</SelectLabel>
{priorityItems.map((o) => {
const alreadyUploaded = uploadedSet.has(o.value);
return (
<SelectItem key={o.value} value={o.value} className="text-xs">
{o.label}
{alreadyUploaded && (
<span className="ml-1.5 text-[10px] text-emerald-500"> uploaded</span>
)}
</SelectItem>
);
})}
</SelectGroup>
)}
{/* 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 (
<SelectGroup key={group}>
@ -326,16 +396,14 @@ function DocTypeSelect({
{group}
</SelectLabel>
{items.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-xs">
{o.label}
</SelectItem>
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
))}
</SelectGroup>
);
})}
</SelectContent>
</Select>
{showHint && selected?.hint && (
{selected?.hint && (
<p className="text-[10px] text-blue-600 leading-snug px-0.5">{selected.hint}</p>
)}
</div>
@ -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<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const [queue, setQueue] = useState<FileEntry[]>([]);
@ -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 (
<div className="space-y-3">
<div className="space-y-4">
{/* PAS guidance */}
<PasGuidancePanel />
{/* Column headers */}
<div className="grid grid-cols-[1fr_260px_180px] gap-2 px-1">
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">File</span>
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Document Type <span className="text-red-400">*</span></span>
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Measure</span>
</div>
{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 (
<div key={entry.id} className="grid grid-cols-[1fr_260px_180px] gap-2 items-center px-1">
<div className="flex items-center gap-2 min-w-0">
<StatusIcon status={entry.status} isExisting={!!entry.existingS3Key} />
<div className="min-w-0">
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
{entry.displaySize && <p className="text-[10px] text-gray-400">{entry.displaySize}</p>}
{entry.existingS3Key && <p className="text-[10px] text-amber-500">Previously uploaded</p>}
</div>
{/* Measure context banner */}
{selectedMeasure && (
<div className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-brandlightblue/20 border border-brandblue/20">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-brandblue">{selectedMeasure}</span>
{(() => {
const mp = measureProgressMap.get(selectedMeasure);
if (!mp) return null;
return (
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full border ${
mp.isComplete ? "bg-emerald-50 text-emerald-700 border-emerald-200" : "bg-amber-50 text-amber-700 border-amber-200"
}`}>
{mp.uploadedCount}/{mp.requiredCount} docs uploaded
</span>
);
})()}
</div>
<DocTypeSelect
value={entry.docType}
onChange={(v) => updateEntryField(entry.id, "docType", v)}
showHint
requiredDocs={requiredDocs}
uploadedDocs={uploadedDocs}
/>
{measures.length > 0 ? (
<Select value={entry.measureName || "__none__"} onValueChange={(v) => updateEntryField(entry.id, "measureName", v === "__none__" ? "" : v)}>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs text-gray-400"> None </SelectItem>
{measures.map((m) => (
<SelectItem key={m} value={m} className="text-xs">{m}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="text-xs text-gray-300"></span>
)}
<button
type="button"
onClick={() => setPhase("measure-select")}
className="text-[10px] text-brandblue/60 hover:text-brandblue underline underline-offset-2"
>
Change
</button>
</div>
);
})}
)}
{/* File list with classification */}
<div className="space-y-3">
{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 (
<div key={entry.id} className="rounded-lg border border-gray-100 bg-gray-50/50 p-3 space-y-2.5">
{/* File info row */}
<div className="flex items-center gap-2 min-w-0">
<StatusIcon status={entry.status} isExisting={!!entry.existingS3Key} />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
<p className="text-[10px] text-gray-400">
{entry.existingS3Key ? "Previously uploaded · " : ""}
{entry.displaySize ?? ""}
{entryMeasure && !selectedMeasure && (
<span className="ml-1 text-brandblue/70">{entryMeasure}</span>
)}
</p>
</div>
{/* Measure selector — only shown if no pre-selected measure */}
{!selectedMeasure && measures.length > 0 && (
<Select
value={entry.measureName || "__none__"}
onValueChange={(v) => updateEntryField(entry.id, "measureName", v === "__none__" ? "" : v)}
>
<SelectTrigger className="h-7 text-[10px] w-36 shrink-0">
<SelectValue placeholder="Measure…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs text-gray-400"> None </SelectItem>
{measures.map((m) => (
<SelectItem key={m} value={m} className="text-xs">{m}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Doc type selector */}
<div>
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide mb-1.5">
Document type <span className="text-red-400">*</span>
</p>
{requiredDocs ? (
<DocTypeButtonGrid
value={entry.docType}
onChange={(v) => updateEntryField(entry.id, "docType", v)}
requiredDocs={requiredDocs}
uploadedDocs={uploadedDocs}
/>
) : (
<DocTypeSelect
value={entry.docType}
onChange={(v) => updateEntryField(entry.id, "docType", v)}
/>
)}
</div>
</div>
);
})}
</div>
{/* Failed uploads (info only) */}
{queue.filter((f) => f.status === "error").length > 0 && (

View file

@ -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<RetroAssessmentFilter>("all");
const [installStatusFilter, setInstallStatusFilter] = useState<InstallStatusFilter>("all");
@ -303,6 +304,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
portfolioId={portfolioId}
onClose={() => setUploadDeal(null)}
docStatusMap={docStatusMap}
approvedMeasures={approvalsByDeal?.[uploadDeal.dealId] ?? []}
/>
)}

View file

@ -243,6 +243,7 @@ export default function LiveTracker({
docStatusMap={docStatusMap}
portfolioId={portfolioId}
userCapability={userCapability}
approvalsByDeal={approvalsByDeal}
/>
</div>
</TabsContent>
@ -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 })
}

View file

@ -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 (
<button
onClick={() => download()}
disabled={signing}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brandblue text-white text-xs font-medium hover:bg-brandblue/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{signing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FileDown className="h-3.5 w-3.5" />
)}
{signing ? "Preparing…" : "Download"}
</button>
);
}
// -----------------------------------------------------------------------
// 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 (
<motion.div
layout
@ -130,19 +150,7 @@ function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?
</div>
</div>
{/* Right: download button */}
<button
onClick={() => download()}
disabled={signing}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brandblue text-white text-xs font-medium hover:bg-brandblue/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{signing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FileDown className="h-3.5 w-3.5" />
)}
{signing ? "Preparing…" : "Download"}
</button>
<DownloadDocButton doc={doc} />
</motion.div>
);
}
@ -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"
>
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5 flex items-center gap-1.5">
<HardHat className="h-3.5 w-3.5" />
Install Documents
</h3>
{installDocs.length > 0 ? (
{docStatus?.measureProgress && docStatus.measureProgress.length > 0 ? (
// ── Per-measure checklist ──
<div className="space-y-4">
{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 (
<div key={mp.measureName} className="rounded-xl border border-gray-100 bg-gray-50/40 overflow-hidden">
{/* Measure header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-gray-100 bg-white">
<span className="text-xs font-semibold text-gray-800">{mp.measureName}</span>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border ${
mp.isComplete
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: mp.uploadedCount > 0
? "bg-amber-50 text-amber-700 border-amber-200"
: "bg-gray-100 text-gray-500 border-gray-200"
}`}>
{mp.uploadedCount} / {mp.requiredCount} docs
</span>
</div>
<div className="px-3 py-2.5 space-y-1.5">
{/* Uploaded required docs */}
{mp.uploaded.map((docType) => {
const doc = measureDocs.find((d) => d.docType === docType);
if (!doc) return null;
return (
<div key={docType} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-emerald-100 bg-emerald-50/50">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0 w-5 h-5 rounded-full bg-emerald-100 border border-emerald-200 flex items-center justify-center">
<svg className="h-3 w-3 text-emerald-600" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[docType] ?? docType}</p>
<p className="text-[10px] text-gray-400">{formatDate(doc.s3UploadTimestamp)}</p>
</div>
</div>
<DownloadDocButton doc={doc} />
</div>
);
})}
{/* Missing required docs */}
{missingTypes.map((docType) => (
<div key={docType} className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-dashed border-amber-200 bg-amber-50/30">
<div className="shrink-0 w-5 h-5 rounded-full border-2 border-dashed border-amber-300 flex items-center justify-center">
<FileX className="h-2.5 w-2.5 text-amber-400" />
</div>
<p className="text-xs text-amber-700 font-medium">{DOC_TYPE_LABELS[docType] ?? docType}</p>
</div>
))}
{/* Extra docs uploaded for this measure (not in required list) */}
{measureDocs
.filter((d) => !mp.required.includes(d.docType))
.map((doc) => (
<div key={doc.id} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-gray-100 bg-white">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0 w-5 h-5 rounded-full bg-sky-50 border border-sky-200 flex items-center justify-center">
<FileText className="h-3 w-3 text-sky-500" />
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[doc.docType] ?? doc.docType}</p>
<p className="text-[10px] text-gray-400">{formatDate(doc.s3UploadTimestamp)}</p>
</div>
</div>
<DownloadDocButton doc={doc} />
</div>
))
}
</div>
</div>
);
})}
{/* 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 (
<div className="space-y-1.5">
<h4 className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 px-0.5">Other</h4>
{unassigned.map((doc) => (
<DocumentRow key={doc.id} doc={doc} showMeasure />
))}
</div>
);
})()}
</div>
) : installDocs.length > 0 ? (
// ── Fallback: flat list (no measure progress data) ──
<div className="space-y-1.5">
{installDocs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} showMeasure />

View file

@ -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<string, string[]>();
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);
}
}