From 785c40f2d11771660197d002b1878ef11be44fe1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 20 Apr 2026 15:55:45 +0000 Subject: [PATCH] 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 ? (