adding measure level tracking for uploaded docs

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-20 15:55:45 +00:00
parent 24edd2656b
commit 785c40f2d1
8 changed files with 351 additions and 27 deletions

View file

@ -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<typeof bodySchema>;
@ -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);

View file

@ -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<void> {
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;

View file

@ -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<string, string[]> = {
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<string, string> = {
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",
};

View file

@ -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<void> {
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 (
<div className="space-y-1">
@ -268,8 +293,32 @@ function DocTypeSelect({ value, onChange, showHint = false }: { value: string; o
</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);
const items = FILE_TYPE_OPTIONS.filter(
(o) => o.group === group && !requiredSet.has(o.value),
);
if (!items.length) return null;
return (
<SelectGroup key={group}>
@ -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<HTMLInputElement>(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<string | null>(null);
// The measure selected in the measure-select phase (empty = "not measure-specific")
const [selectedMeasure, setSelectedMeasure] = useState<string>("");
// ── 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
<DialogHeader>
<DialogTitle>
{phase === "loading" ? "Loading…" :
phase === "measure-select" ? "Select Measure" :
phase === "upload" ? "Upload Documents" :
"Classify Documents"}
</DialogTitle>
<DialogDescription>
{phase === "loading" && "Checking for pending files…"}
{phase === "measure-select" && (
<>
Which measure are you uploading documents for?{" "}
<strong>{propertyLabel}</strong>
</>
)}
{phase === "upload" && (
<>
Upload install documents for <strong>{propertyLabel}</strong>.
{selectedMeasure
? <>Uploading documents for <strong>{selectedMeasure}</strong> <strong>{propertyLabel}</strong>.</>
: <>Upload install documents for <strong>{propertyLabel}</strong>.</>
}
{existingCount > 0 && ` ${existingCount} file${existingCount !== 1 ? "s" : ""} are pending classification.`}
</>
)}
@ -525,6 +590,56 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
</div>
)}
{/* ── 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 (
<div className="space-y-3">
<p className="text-xs text-gray-500">
Select the measure you are uploading documents for. This helps track completion against required documents.
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{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 (
<button
key={measure}
type="button"
onClick={() => { setSelectedMeasure(measure); setPhase("upload"); }}
className={`flex flex-col items-start gap-1 rounded-lg border px-3 py-2.5 text-left transition-colors hover:border-brandblue/40 hover:bg-brandlightblue/10 ${
isComplete
? "border-emerald-200 bg-emerald-50/60"
: uploaded > 0
? "border-amber-200 bg-amber-50/50"
: "border-gray-200 bg-white"
}`}
>
<span className="text-xs font-semibold text-gray-800 leading-tight">{measure}</span>
<span className={`text-[10px] font-medium ${isComplete ? "text-emerald-600" : uploaded > 0 ? "text-amber-600" : "text-gray-400"}`}>
{isComplete ? "✓ Complete" : `${uploaded} / ${required} docs`}
</span>
</button>
);
})}
</div>
<button
type="button"
onClick={() => { setSelectedMeasure(""); setPhase("upload"); }}
className="text-xs text-gray-400 hover:text-gray-600 underline underline-offset-2 mt-1"
>
Not measure-specific / other
</button>
</div>
);
})()}
{/* ── 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 (
<div className="space-y-3">
{/* PAS guidance */}
<PasGuidancePanel />
@ -604,7 +726,11 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Measure</span>
</div>
{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 (
<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} />
@ -614,7 +740,13 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
{entry.existingS3Key && <p className="text-[10px] text-amber-500">Previously uploaded</p>}
</div>
</div>
<DocTypeSelect value={entry.docType} onChange={(v) => updateEntryField(entry.id, "docType", v)} showHint />
<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">
@ -631,7 +763,8 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
<span className="text-xs text-gray-300"></span>
)}
</div>
))}
);
})}
{/* Failed uploads (info only) */}
{queue.filter((f) => f.status === "error").length > 0 && (
@ -651,7 +784,8 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
</p>
)}
</div>
)}
);
})()}
</div>
<DialogFooter className="pt-2 border-t border-gray-100 shrink-0">
@ -659,6 +793,10 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
<Button variant="secondary" onClick={onClose}>Cancel</Button>
)}
{phase === "measure-select" && (
<Button variant="secondary" onClick={onClose}>Cancel</Button>
)}
{phase === "upload" && (
<>
<Button variant="secondary" onClick={onClose} disabled={isUploading}>Cancel</Button>

View file

@ -302,6 +302,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
deal={uploadDeal}
portfolioId={portfolioId}
onClose={() => setUploadDeal(null)}
docStatusMap={docStatusMap}
/>
)}

View file

@ -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 (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-emerald-50 text-emerald-700 border-emerald-200">
<span
title={tooltip}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-emerald-50 text-emerald-700 border-emerald-200"
>
<CheckCircle2 className="h-3.5 w-3.5" />
All Measures
</span>
);
}
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 (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-amber-50 text-amber-700 border-amber-200">
<span
title={tooltip}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-amber-50 text-amber-700 border-amber-200"
>
<AlertCircle className="h-3.5 w-3.5" />
Some Measures
{label}
</span>
);
}

View file

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

View file

@ -250,6 +250,16 @@ export const SURVEY_ALL_DOC_TYPES = new Set<string>([
"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<string, DocStatus>; // keyed by UPRN string