diff --git a/src/app/api/live-tracking/property-documents/route.ts b/src/app/api/live-tracking/property-documents/route.ts index 55774d8a..35f49915 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/api/upload/contractor-install/route.ts b/src/app/api/upload/contractor-install/route.ts index 0f9376b1..6dc6b662 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 f16b1816..563c7034 100644 --- a/src/app/lib/hubspot/dealSync.ts +++ b/src/app/lib/hubspot/dealSync.ts @@ -71,16 +71,43 @@ 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; + let uploadStatus: 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(" | "); + uploadStatus = params.measureProgress.every((m) => m.isComplete) + ? "Upload Complete for All Measures" + : "Upload in progress"; + } else { + log = "Documents available - uploaded by contractor"; + uploadStatus = "Upload in progress"; + } + 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, + contractor_document_upload_status: uploadStatus, }, }); return; diff --git a/src/app/lib/measureDocumentRequirements.ts b/src/app/lib/measureDocumentRequirements.ts new file mode 100644 index 00000000..e196faf9 --- /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 1fa07b79..ccb1db8c 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,14 @@ 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; + approvedMeasures?: string[]; // if non-empty, used instead of proposedMeasures }; // ── Constants ───────────────────────────────────────────────────────────── @@ -200,11 +203,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,7 +262,121 @@ function PasGuidancePanel() { // ── DocType select ──────────────────────────────────────────────────────── -function DocTypeSelect({ value, onChange, showHint = false }: { value: string; onChange: (v: string) => void; showHint?: boolean }) { +// ── DocType button grid — shown when a measure is selected ─────────────── + +function DocTypeButtonGrid({ + value, + onChange, + requiredDocs, + uploadedDocs, +}: { + value: string; + onChange: (v: string) => void; + 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); return ( @@ -277,16 +396,14 @@ function DocTypeSelect({ value, onChange, showHint = false }: { value: string; o {group} {items.map((o) => ( - - {o.label} - + {o.label} ))} ); })} - {showHint && selected?.hint && ( + {selected?.hint && (

{selected.hint}

)} @@ -305,8 +422,11 @@ function StatusIcon({ status, isExisting, errorMsg }: { status: FileStatus; isEx // ── Main component ───────────────────────────────────────────────────────── -export default function ContractorUploadModal({ deal, portfolioId, onClose }: 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([]); @@ -314,6 +434,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 +444,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 +472,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 +497,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 +597,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 +622,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 +661,55 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr )} + {/* ── Measure Select ── */} + {phase === "measure-select" && (() => { + const docStatus = docStatusMap?.[deal.dealId]; + 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,46 +777,107 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr )} {/* ── Phase 2: Classify ── */} - {phase === "classify" && ( -
+ {phase === "classify" && (() => { + const docStatus = docStatusMap?.[deal.dealId]; + const measureProgressMap = new Map( + (docStatus?.measureProgress ?? []).map((m) => [m.measureName, m]), + ); + return ( +
{/* PAS guidance */} - {/* Column headers */} -
- File - Document Type * - Measure -
- - {classifiableEntries.map((entry) => ( -
-
- -
-

{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 /> - {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 && ( @@ -651,7 +897,8 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr

)}
- )} + ); + })()}
@@ -659,6 +906,10 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr )} + {phase === "measure-select" && ( + + )} + {phase === "upload" && ( <> diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DampMouldRiskPanel.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DampMouldRiskPanel.tsx index 4b257092..ffebcd4c 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DampMouldRiskPanel.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DampMouldRiskPanel.tsx @@ -121,6 +121,7 @@ export default function DampMouldRiskPanel({ "dealname", "landlordPropertyId", "dampMouldFlag", + "dampMouldAndRepairComments", "coordinator", ]; @@ -128,6 +129,7 @@ export default function DampMouldRiskPanel({ dealname: "Address", landlordPropertyId: "Property Ref", dampMouldFlag: "Coordinator Flag", + dampMouldAndRepairComments: "Comments", coordinator: "Coordinator", }; 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 01baed96..8efbcb81 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx @@ -29,17 +29,18 @@ 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"; 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; + 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"); @@ -66,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; @@ -114,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 @@ -302,6 +303,8 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo deal={uploadDeal} portfolioId={portfolioId} onClose={() => setUploadDeal(null)} + docStatusMap={docStatusMap} + approvedMeasures={approvalsByDeal?.[uploadDeal.dealId] ?? []} /> )} 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 d630130e..f1371e79 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx @@ -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 ( - + All Measures ); } 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 ( - + - Some Measures + {label} ); } @@ -82,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[] { @@ -119,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, @@ -136,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; @@ -145,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, @@ -158,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; @@ -182,6 +200,7 @@ export function createDocumentTableColumns( + ); +} + +// ----------------------------------------------------------------------- +// 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 */} - + ); } @@ -152,29 +160,34 @@ function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure? // ----------------------------------------------------------------------- interface PropertyDrawerProps { open: boolean; + dealId?: string | null; uprn: string | null; landlordPropertyId: string | null; dealname: string | null; + docStatus?: DocStatus; onClose: () => void; } export default function PropertyDrawer({ open, + dealId, uprn, landlordPropertyId, dealname, + docStatus, onClose, }: PropertyDrawerProps) { - const canQuery = !!(uprn || landlordPropertyId); + const canQuery = !!(dealId || uprn || landlordPropertyId); const { data: fetchedDocuments = [], isFetching, isError, } = useQuery({ - queryKey: ["property-documents", uprn, landlordPropertyId], + queryKey: ["property-documents", dealId, uprn, landlordPropertyId], queryFn: async () => { const params = new URLSearchParams(); - if (uprn) params.set("uprn", uprn); + if (dealId) params.set("dealId", dealId); + else if (uprn) params.set("uprn", uprn); else if (landlordPropertyId) params.set("landlordPropertyId", landlordPropertyId); const res = await fetch( @@ -361,13 +374,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/PropertyTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx index 595106f7..d91ff6d3 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx @@ -52,6 +52,10 @@ const COLUMN_LABELS: Record = { approvedPackage: "Approved Package", actualMeasuresInstalled: "Installed Measures", preSapScore: "Pre-SAP", + eiScore: "EI Score", + eiScorePotential: "EI Score (Potential)", + epcSapScore: "EPC SAP Score", + epcSapScorePotential: "EPC SAP (Potential)", lodgementStatus: "Lodgement Status", designDate: "Design Date", fullLodgementDate: "Lodgement Date", @@ -62,7 +66,7 @@ type RemovalFilter = "all" | "pending_removal" | "removed" | "pending_re_additio interface PropertyTableProps { 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; onOpenDetail?: (deal: ClassifiedDeal) => void; showDocuments?: boolean; docStatusMap?: DocStatusMap; @@ -82,6 +86,10 @@ const CSV_FIELDS: { key: keyof ClassifiedDeal; label: string }[] = [ { key: "approvedPackage", label: "Approved Package" }, { key: "actualMeasuresInstalled", label: "Installed Measures" }, { key: "preSapScore", label: "Pre-SAP" }, + { key: "eiScore", label: "EI Score" }, + { key: "eiScorePotential", label: "EI Score (Potential)" }, + { key: "epcSapScore", label: "EPC SAP Score" }, + { key: "epcSapScorePotential", label: "EPC SAP (Potential)" }, { key: "lodgementStatus", label: "Lodgement Status" }, { key: "designDate", label: "Design Date" }, { key: "fullLodgementDate", label: "Lodgement Date" }, @@ -115,6 +123,10 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo approvedPackage: false, actualMeasuresInstalled: false, preSapScore: false, + eiScore: false, + eiScorePotential: false, + epcSapScore: false, + epcSapScorePotential: false, lodgementStatus: false, designDate: false, fullLodgementDate: false, @@ -128,7 +140,7 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo } if (docFilter !== "all") { result = result.filter((d) => { - const status = d.uprn ? docStatusMap[d.uprn] : undefined; + const status = docStatusMap[d.dealId]; if (docFilter === "none") return !status || !status.hasSurveyDocs; if (docFilter === "has_docs") return !!status?.hasSurveyDocs; if (docFilter === "incomplete") return !!status?.hasSurveyDocs && !status.isSurveyComplete; diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx index 5d7b6934..63e62dd2 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx @@ -42,10 +42,10 @@ function SortableHeader({ // ----------------------------------------------------------------------- // Column factory — takes onOpenDrawer so the Documents button can trigger it // showDocuments controls whether the Docs action column is included -// docStatusMap provides per-UPRN document status for status indicators +// docStatusMap provides per-deal document status for status indicators // ----------------------------------------------------------------------- export function createPropertyTableColumns( - onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void, + onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void, showDocuments: boolean = false, docStatusMap: DocStatusMap = {}, onOpenDetail?: (deal: ClassifiedDeal) => void, @@ -240,6 +240,54 @@ export function createPropertyTableColumns( }, + // ── EI score ───────────────────────────────────────────────────────── + { + accessorKey: "eiScore", + id: "eiScore", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.eiScore ?? } + + ), + }, + + // ── EI score (potential) ────────────────────────────────────────────── + { + accessorKey: "eiScorePotential", + id: "eiScorePotential", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.eiScorePotential ?? } + + ), + }, + + // ── EPC SAP score ───────────────────────────────────────────────────── + { + accessorKey: "epcSapScore", + id: "epcSapScore", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.epcSapScore ?? } + + ), + }, + + // ── EPC SAP score (potential) ───────────────────────────────────────── + { + accessorKey: "epcSapScorePotential", + id: "epcSapScorePotential", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.epcSapScorePotential ?? } + + ), + }, + // ── Lodgement status ───────────────────────────────────────────────── { accessorKey: "lodgementStatus", @@ -291,8 +339,7 @@ export function createPropertyTableColumns( Docs ), cell: ({ row }) => { - const uprn = row.original.uprn ?? ""; - const status = uprn ? docStatusMap[uprn] : undefined; + const status = docStatusMap[row.original.dealId]; const isComplete = status?.isSurveyComplete; const hasDocs = status?.hasSurveyDocs; @@ -315,7 +362,7 @@ export function createPropertyTableColumns( return (