From e2b7bc9d9312329ef1dcdf60e8f084cead2cc1d6 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 8 May 2026 10:45:40 +0000 Subject: [PATCH] moving the documents ui to the deal page view --- .../live/PropertyDocumentsContent.tsx | 398 +++++++++++++++++ .../your-projects/live/PropertyDrawer.tsx | 411 +----------------- .../your-projects/live/[dealId]/DealPage.tsx | 86 ++-- .../live/propertyDocuments.test.ts | 137 ++++++ .../your-projects/live/propertyDocuments.ts | 28 ++ src/app/utils.test.ts | 37 ++ 6 files changed, 643 insertions(+), 454 deletions(-) create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDocumentsContent.tsx create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyDocuments.test.ts create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyDocuments.ts create mode 100644 src/app/utils.test.ts diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDocumentsContent.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDocumentsContent.tsx new file mode 100644 index 0000000..6c6d2bb --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDocumentsContent.tsx @@ -0,0 +1,398 @@ +"use client"; + +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { motion, AnimatePresence } from "framer-motion"; +import { + FileDown, + FileText, + FileX, + Loader2, + FolderOpen, + ExternalLink, + HardHat, + Upload, +} from "lucide-react"; +import type { PropertyDocument, DocStatus } from "./types"; +import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES } from "./types"; +import { splitDocumentsByType, getMissingRetrofitTypes, getUnassignedInstallDocs } from "./propertyDocuments"; +import ContractorUploadModal from "./ContractorUploadModal"; +import type { ClassifiedDeal, PortfolioCapabilityType } from "./types"; + +export const DOC_TYPE_LABELS: Record = { + photo_pack: "Photo Pack", + site_note: "Site Note", + rd_sap_site_note: "RdSAP Site Note", + pas_2023_ventilation: "PAS 2023 Ventilation", + pas_2023_condition: "PAS 2023 Condition Report", + pas_significance: "PAS Significance", + par_photo_pack: "PAR Photo Pack", + pas_2023_property: "PAS 2023 Property Report", + pas_2023_occupancy: "PAS 2023 Occupancy Report", + ecmk_site_note: "ECMK Site Note", + ecmk_rd_sap_site_note: "ECMK RdSAP Site Note", + ecmk_survey_xml: "ECMK Survey XML", + 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", + insurance_guarantee: "Insurance Backed Guarantee (IBG)", + workmanship_warranty: "Workmanship Warranty", + g98_notification: "G98 / G99 Notification", + installer_qualifications: "Installer Qualifications", + installer_feedback: "Installer Feedback", + contractor_other: "Other", +}; + +function formatDocDate(iso: string): string { + try { + return new Date(iso).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); + } catch { + return iso; + } +} + +export function DownloadDocButton({ doc }: { doc: PropertyDocument }) { + const { mutate: download, isPending: signing } = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/sign-document-url", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: doc.s3FileKey, bucket: doc.s3FileBucket }), + }); + if (!res.ok) throw new Error("Failed to get signed URL"); + const data = await res.json(); + return data.url as string; + }, + onSuccess: (url) => { + window.open(url, "_blank"); + }, + }); + + return ( + + ); +} + +export function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) { + const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType; + + return ( + +
+
+ +
+
+

{label}

+

+ {showMeasure && doc.measureName ? ( + <> + {doc.measureName}{" "} + · {formatDocDate(doc.s3UploadTimestamp)} + + ) : ( + formatDocDate(doc.s3UploadTimestamp) + )} +

+
+
+ +
+ ); +} + +interface PropertyDocumentsContentProps { + documents: PropertyDocument[]; + isFetching: boolean; + isError: boolean; + docStatus: DocStatus; + // Upload (contractor-only, all required to show button) + deal?: ClassifiedDeal; + portfolioId?: string; + userCapability?: PortfolioCapabilityType; + approvedMeasures?: string[]; +} + +export default function PropertyDocumentsContent({ + documents, + isFetching, + isError, + docStatus, + deal, + portfolioId, + userCapability, + approvedMeasures, +}: PropertyDocumentsContentProps) { + const [uploadOpen, setUploadOpen] = useState(false); + + const { retrofitDocs, installDocs } = splitDocumentsByType(documents); + const missingRetrofitTypes = getMissingRetrofitTypes(retrofitDocs); + const hasDocuments = documents.length > 0; + + const isContractor = userCapability?.includes("contractor") ?? false; + const canUpload = isContractor && !!deal && !!portfolioId; + + const docStatusMap = deal + ? { [deal.dealId]: docStatus } + : undefined; + + return ( +
+ {/* Upload button */} + {canUpload && ( +
+ +
+ )} + + {/* Loading */} + {isFetching && ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ )} + + {/* Error */} + {isError && !isFetching && ( +
+
+ +
+

Could not load documents

+

Please try again later.

+
+ )} + + {/* Empty */} + {!isFetching && !isError && !hasDocuments && ( +
+
+
+ +
+

No documents available

+

+ All {EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.length} retrofit assessment documents are outstanding. +

+
+
+

+ Missing Documents ({missingRetrofitTypes.length}) +

+ {missingRetrofitTypes.map((t) => ( +
+ + + {DOC_TYPE_LABELS[t] ?? t} + +
+ ))} +
+
+ )} + + {/* Documents */} + + {!isFetching && !isError && hasDocuments && ( + <> + {/* Retrofit Assessment */} + +

+ Retrofit Assessment Documents +

+ {retrofitDocs.length > 0 ? ( +
+ {retrofitDocs.map((doc) => ( + + ))} +
+ ) : ( +

None uploaded yet.

+ )} + {missingRetrofitTypes.length > 0 && ( +
+

+ Missing ({missingRetrofitTypes.length}) +

+ {missingRetrofitTypes.map((t) => ( +
+ + + {DOC_TYPE_LABELS[t] ?? t} + +
+ ))} +
+ )} +
+ + {/* Install Documents */} + +

+ + Install Documents +

+ + {docStatus.measureProgress.length > 0 ? ( +
+ {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 ( +
+
+ {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 + +
+
+ {mp.uploaded.map((docType) => { + const doc = measureDocs.find((d) => d.docType === docType); + if (!doc) return null; + return ( +
+
+
+ + + +
+
+

{DOC_TYPE_LABELS[docType] ?? docType}

+

{formatDocDate(doc.s3UploadTimestamp)}

+
+
+ +
+ ); + })} + {missingTypes.map((docType) => ( +
+
+ +
+

{DOC_TYPE_LABELS[docType] ?? docType}

+
+ ))} + {measureDocs + .filter((d) => !mp.required.includes(d.docType)) + .map((doc) => ( +
+
+
+ +
+
+

{DOC_TYPE_LABELS[doc.docType] ?? doc.docType}

+

{formatDocDate(doc.s3UploadTimestamp)}

+
+
+ +
+ ))} +
+
+ ); + })} + + {/* Unassigned install docs */} + {(() => { + const unassigned = getUnassignedInstallDocs(installDocs, docStatus.measureProgress); + if (unassigned.length === 0) return null; + return ( +
+

Other

+ {unassigned.map((doc) => ( + + ))} +
+ ); + })()} +
+ ) : installDocs.length > 0 ? ( +
+ {installDocs.map((doc) => ( + + ))} +
+ ) : ( +

No install documents uploaded yet.

+ )} +
+ + )} +
+ + {/* Upload modal */} + {canUpload && uploadOpen && deal && portfolioId && ( + setUploadOpen(false)} + docStatusMap={docStatusMap} + approvedMeasures={approvedMeasures} + /> + )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx index 83118c0..210ff48 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx @@ -1,18 +1,8 @@ "use client"; -import { useQuery, useMutation } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useRef } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { - FileDown, - FileText, - FileX, - Loader2, - FolderOpen, - X, - ExternalLink, - HardHat, -} from "lucide-react"; +import { FileDown, X } from "lucide-react"; import { Drawer, DrawerClose, @@ -22,138 +12,7 @@ import { DrawerDescription, } from "@/app/shadcn_components/ui/drawer"; 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 -const DOC_TYPE_LABELS: Record = { - // Survey / retrofit assessment docs - photo_pack: "Photo Pack", - site_note: "Site Note", - rd_sap_site_note: "RdSAP Site Note", - pas_2023_ventilation: "PAS 2023 Ventilation", - pas_2023_condition: "PAS 2023 Condition Report", - pas_significance: "PAS Significance", - par_photo_pack: "PAR Photo Pack", - pas_2023_property: "PAS 2023 Property Report", - pas_2023_occupancy: "PAS 2023 Occupancy Report", - ecmk_site_note: "ECMK Site Note", - ecmk_rd_sap_site_note: "ECMK RdSAP Site Note", - ecmk_survey_xml: "ECMK Survey XML", - // Install docs — photos - 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", - // Install docs — pre-installation - pre_installation_building_inspection: "PIBI / Tech Survey", - point_of_work_risk_assessment: "Point of Work Risk Assessment", - // Install docs — compliance & lodgement - 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", - // Install docs — ventilation - ventilation_assessment_checklist: "Ventilation Assessment Checklist", - anemometer_readings: "Anemometer Readings", - commissioning_records: "Commissioning Records", - part_f_ventilation_document: "Approved Document Part F", - // Install docs — handover & warranties - handover_pack: "Handover Pack", - insurance_guarantee: "Insurance Backed Guarantee (IBG)", - workmanship_warranty: "Workmanship Warranty", - g98_notification: "G98 / G99 Notification", - // Install docs — qualifications & other - installer_qualifications: "Installer Qualifications", - installer_feedback: "Installer Feedback", - contractor_other: "Other", -}; - -function formatDate(iso: string): string { - try { - return new Date(iso).toLocaleDateString("en-GB", { - day: "numeric", - month: "short", - year: "numeric", - }); - } catch { - return iso; - } -} - -// ----------------------------------------------------------------------- -// Reusable download button — encapsulates the presigned URL mutation -// ----------------------------------------------------------------------- -function DownloadDocButton({ doc }: { doc: PropertyDocument }) { - const { mutate: download, isPending: signing } = useMutation({ - mutationFn: async () => { - const res = await fetch("/api/sign-document-url", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key: doc.s3FileKey, bucket: doc.s3FileBucket }), - }); - if (!res.ok) throw new Error("Failed to get signed URL"); - const data = await res.json(); - return data.url as string; - }, - onSuccess: (url) => { - window.open(url, "_blank"); - }, - }); - - return ( - - ); -} - -// ----------------------------------------------------------------------- -// Individual document row — used in retrofit section and install fallback -// ----------------------------------------------------------------------- -function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) { - const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType; - - return ( - - {/* Left: icon + label + date stacked */} -
-
- -
-
-

{label}

-

- {showMeasure && doc.measureName - ? <>{doc.measureName} · {formatDate(doc.s3UploadTimestamp)} - : formatDate(doc.s3UploadTimestamp) - } -

-
-
- - -
- ); -} +import PropertyDocumentsContent from "./PropertyDocumentsContent"; // ----------------------------------------------------------------------- // PropertyDrawer — main component @@ -190,9 +49,7 @@ export default function PropertyDrawer({ else if (uprn) params.set("uprn", uprn); else if (landlordPropertyId) params.set("landlordPropertyId", landlordPropertyId); - const res = await fetch( - `/api/live-tracking/property-documents?${params}`, - ); + const res = await fetch(`/api/live-tracking/property-documents?${params}`); if (!res.ok) throw new Error("Failed to load documents"); return res.json() as Promise; }, @@ -200,31 +57,17 @@ export default function PropertyDrawer({ staleTime: 30_000, }); - // Keep the last successfully fetched result so the closing animation doesn't - // flash the empty state (the parent nulls out uprn/landlordPropertyId on close, - // which disables the query and resets fetchedDocuments to [] mid-animation). + // Preserve last fetched docs so the closing animation doesn't flash empty state + // when the parent nulls identifiers mid-animation. const lastDocumentsRef = useRef([]); if (open && !isFetching && !isError) { lastDocumentsRef.current = fetchedDocuments as PropertyDocument[]; } const documents = open ? (fetchedDocuments as PropertyDocument[]) : lastDocumentsRef.current; - // Split documents into the two sections - const retrofitDocs = documents.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.docType)); - const installDocs = documents.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.docType)); - - const hasDocuments = documents.length > 0; - - // Missing mandatory retrofit assessment docs (ecmk types are optional — not shown as missing) - const presentRetrofitTypes = new Set(retrofitDocs.map((d) => d.docType)); - const missingRetrofitTypes = EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.filter( - (t) => !presentRetrofitTypes.has(t), - ); - return ( !v && onClose()} direction="right"> - {/* Remove the default drag handle */}
@@ -253,7 +96,7 @@ export default function PropertyDrawer({
- {hasDocuments && !isFetching && ( + {documents.length > 0 && !isFetching && (
@@ -263,242 +106,18 @@ export default function PropertyDrawer({ )} - {/* Body */} -
- {/* Loading state */} - {isFetching && ( -
- {[1, 2, 3].map((i) => ( -
- ))} -
- )} - - {/* Error state */} - {isError && !isFetching && ( -
-
- -
-

- Could not load documents -

-

- Please try again later. -

-
- )} - - {/* Empty state */} - {!isFetching && !isError && !hasDocuments && ( -
-
-
- -
-

- No documents available -

-

- All {EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.length} retrofit assessment documents are outstanding. -

-
-
-

- Missing Documents ({missingRetrofitTypes.length}) -

- {missingRetrofitTypes.map((t) => ( -
- - - {DOC_TYPE_LABELS[t] ?? t} - -
- ))} -
-
- )} - - - {!isFetching && !isError && hasDocuments && ( - <> - {/* ── Retrofit Assessment Documents ── */} - -

- Retrofit Assessment Documents -

- {retrofitDocs.length > 0 ? ( -
- {retrofitDocs.map((doc) => ( - - ))} -
- ) : ( -

None uploaded yet.

- )} - - {/* Missing mandatory retrofit assessment docs */} - {missingRetrofitTypes.length > 0 && ( -
-

- Missing ({missingRetrofitTypes.length}) -

- {missingRetrofitTypes.map((t) => ( -
- - - {DOC_TYPE_LABELS[t] ?? t} - -
- ))} -
- )} -
- - {/* ── Install Documents ── */} - -

- - Install Documents -

- - {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) => ( - - ))} -
- ) : ( -

No install documents uploaded yet.

- )} -
- - )} -
+
+
- {/* Footer */}

- Download links expire after 30 minutes. Refresh to generate a new - link. + Download links expire after 30 minutes. Refresh to generate a new link.

diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx index 0a101dc..a55dc46 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useSearchParams, useRouter } from "next/navigation"; import { Dialog, @@ -18,7 +19,8 @@ import { AlertTriangle, ChevronRight, ChevronDown } from "lucide-react"; import { sapToEpc, getEpcAccentClasses, parsePreSap } from "@/app/utils"; import { parseMeasures } from "@/app/lib/parseMeasures"; import { outOfOrderInstructionWarning } from "@/app/lib/softWarnings"; -import type { ClassifiedDeal, PortfolioCapabilityType, DocStatus, EffectiveRemovalState } from "../types"; +import type { ClassifiedDeal, PortfolioCapabilityType, DocStatus, EffectiveRemovalState, PropertyDocument } from "../types"; +import PropertyDocumentsContent from "../PropertyDocumentsContent"; import { STAGE_COLORS } from "../types"; import { InfoRow, @@ -78,6 +80,20 @@ export default function DealPage({ router.replace(`?tab=${tab}`, { scroll: false }); }; + const { data: documents = [], isFetching: docsFetching, isError: docsError } = useQuery({ + queryKey: ["property-documents", deal.dealId, deal.uprn, deal.landlordPropertyId], + queryFn: async () => { + const params = new URLSearchParams(); + if (deal.dealId) params.set("dealId", deal.dealId); + else if (deal.uprn) params.set("uprn", deal.uprn); + else if (deal.landlordPropertyId) params.set("landlordPropertyId", deal.landlordPropertyId); + const res = await fetch(`/api/live-tracking/property-documents?${params}`); + if (!res.ok) throw new Error("Failed to load documents"); + return res.json() as Promise; + }, + staleTime: 30_000, + }); + const parsedPreSap = parsePreSap(deal.preSapScore); const epcPotential = sapToEpc(deal.epcSapScorePotential != null ? Number(deal.epcSapScorePotential) : null); const technicalApprovedMeasures = parseMeasures( @@ -322,64 +338,18 @@ export default function DealPage({ {/* ── Documents ── */}
-

- Documents -

- {docStatus.hasSurveyDocs || docStatus.hasInstallDocs ? ( -
-
- - - Survey docs:{" "} - - {docStatus.isSurveyComplete - ? "Complete" - : docStatus.hasSurveyDocs - ? `${docStatus.presentSurveyTypes.length} uploaded` - : "None"} - - -
-
- - - Install docs:{" "} - - {docStatus.installStatus === "none" - ? "None" - : docStatus.installStatus === "all" - ? "Complete" - : "Partial"} - - -
- {docStatus.measureProgress.length > 0 && ( -
- {docStatus.measureProgress.map((mp) => ( -
- {mp.measureName} - - {mp.uploadedCount}/{mp.requiredCount} - -
- ))} -
- )} -
- ) : ( -

No documents uploaded yet.

- )} +
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyDocuments.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyDocuments.test.ts new file mode 100644 index 0000000..c81eb93 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyDocuments.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "vitest"; +import { + splitDocumentsByType, + getMissingRetrofitTypes, + getUnassignedInstallDocs, +} from "./propertyDocuments"; +import type { PropertyDocument, MeasureDocProgress } from "./types"; + +function makeDoc(overrides: Partial = {}): PropertyDocument { + return { + id: "1", + s3FileKey: "key", + s3FileBucket: "bucket", + docType: "photo_pack", + s3UploadTimestamp: "2024-01-01T00:00:00Z", + uprn: null, + landlordPropertyId: null, + measureName: null, + ...overrides, + }; +} + +function makeMeasureProgress(overrides: Partial = {}): MeasureDocProgress { + return { + measureName: "ASHP", + required: ["pre_photo", "post_photo"], + uploaded: [], + isComplete: false, + uploadedCount: 0, + requiredCount: 2, + ...overrides, + }; +} + +describe("splitDocumentsByType", () => { + it("puts survey doc types in retrofitDocs", () => { + const doc = makeDoc({ docType: "photo_pack" }); + const { retrofitDocs, installDocs } = splitDocumentsByType([doc]); + expect(retrofitDocs).toHaveLength(1); + expect(installDocs).toHaveLength(0); + }); + + it("puts install doc types in installDocs", () => { + const doc = makeDoc({ docType: "pre_photo" }); + const { retrofitDocs, installDocs } = splitDocumentsByType([doc]); + expect(retrofitDocs).toHaveLength(0); + expect(installDocs).toHaveLength(1); + }); + + it("includes optional ecmk types in retrofitDocs", () => { + const doc = makeDoc({ docType: "ecmk_site_note" }); + const { retrofitDocs } = splitDocumentsByType([doc]); + expect(retrofitDocs).toHaveLength(1); + }); + + it("splits a mixed list correctly", () => { + const docs = [ + makeDoc({ id: "1", docType: "photo_pack" }), + makeDoc({ id: "2", docType: "pre_photo" }), + makeDoc({ id: "3", docType: "site_note" }), + makeDoc({ id: "4", docType: "post_photo" }), + ]; + const { retrofitDocs, installDocs } = splitDocumentsByType(docs); + expect(retrofitDocs).toHaveLength(2); + expect(installDocs).toHaveLength(2); + }); + + it("returns empty arrays for empty input", () => { + const { retrofitDocs, installDocs } = splitDocumentsByType([]); + expect(retrofitDocs).toHaveLength(0); + expect(installDocs).toHaveLength(0); + }); +}); + +describe("getMissingRetrofitTypes", () => { + it("returns all mandatory types when no docs uploaded", () => { + const missing = getMissingRetrofitTypes([]); + expect(missing).toHaveLength(9); + }); + + it("excludes types that have been uploaded", () => { + const uploaded = [makeDoc({ docType: "photo_pack" })]; + const missing = getMissingRetrofitTypes(uploaded); + expect(missing).not.toContain("photo_pack"); + expect(missing).toHaveLength(8); + }); + + it("returns empty array when all mandatory types uploaded", () => { + const uploaded = [ + "photo_pack", "site_note", "rd_sap_site_note", "pas_2023_ventilation", + "pas_2023_condition", "pas_significance", "par_photo_pack", + "pas_2023_property", "pas_2023_occupancy", + ].map((docType, i) => makeDoc({ id: String(i), docType })); + expect(getMissingRetrofitTypes(uploaded)).toHaveLength(0); + }); + + it("does not count ecmk types as mandatory", () => { + const uploaded = [makeDoc({ docType: "ecmk_site_note" })]; + const missing = getMissingRetrofitTypes(uploaded); + expect(missing).not.toContain("ecmk_site_note"); + expect(missing).toHaveLength(9); + }); +}); + +describe("getUnassignedInstallDocs", () => { + it("returns docs with no measureName", () => { + const doc = makeDoc({ docType: "pre_photo", measureName: null }); + const result = getUnassignedInstallDocs([doc], [makeMeasureProgress()]); + expect(result).toContain(doc); + }); + + it("returns docs whose measureName is not in measureProgress", () => { + const doc = makeDoc({ docType: "pre_photo", measureName: "SolarPV" }); + const progress = [makeMeasureProgress({ measureName: "ASHP" })]; + const result = getUnassignedInstallDocs([doc], progress); + expect(result).toContain(doc); + }); + + it("excludes docs assigned to a known measure", () => { + const doc = makeDoc({ docType: "pre_photo", measureName: "ASHP" }); + const progress = [makeMeasureProgress({ measureName: "ASHP" })]; + const result = getUnassignedInstallDocs([doc], progress); + expect(result).toHaveLength(0); + }); + + it("returns empty array when all docs are assigned", () => { + const docs = [ + makeDoc({ id: "1", docType: "pre_photo", measureName: "ASHP" }), + makeDoc({ id: "2", docType: "post_photo", measureName: "CWI" }), + ]; + const progress = [ + makeMeasureProgress({ measureName: "ASHP" }), + makeMeasureProgress({ measureName: "CWI" }), + ]; + expect(getUnassignedInstallDocs(docs, progress)).toHaveLength(0); + }); +}); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyDocuments.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyDocuments.ts new file mode 100644 index 0000000..65a5e9e --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/propertyDocuments.ts @@ -0,0 +1,28 @@ +import { + SURVEY_ALL_DOC_TYPES, + EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, +} from "./types"; +import type { PropertyDocument, MeasureDocProgress } from "./types"; + +export function splitDocumentsByType(docs: PropertyDocument[]): { + retrofitDocs: PropertyDocument[]; + installDocs: PropertyDocument[]; +} { + return { + retrofitDocs: docs.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.docType)), + installDocs: docs.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.docType)), + }; +} + +export function getMissingRetrofitTypes(retrofitDocs: PropertyDocument[]): string[] { + const present = new Set(retrofitDocs.map((d) => d.docType)); + return EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.filter((t) => !present.has(t)); +} + +export function getUnassignedInstallDocs( + installDocs: PropertyDocument[], + measureProgress: MeasureDocProgress[], +): PropertyDocument[] { + const knownMeasures = new Set(measureProgress.map((m) => m.measureName)); + return installDocs.filter((d) => !d.measureName || !knownMeasures.has(d.measureName)); +} diff --git a/src/app/utils.test.ts b/src/app/utils.test.ts new file mode 100644 index 0000000..7f1117c --- /dev/null +++ b/src/app/utils.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { parsePreSap } from "./utils"; + +describe("parsePreSap", () => { + it("returns null for null", () => { + expect(parsePreSap(null)).toBeNull(); + }); + + it("returns null for undefined", () => { + expect(parsePreSap(undefined)).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(parsePreSap("")).toBeNull(); + }); + + it("parses new format with letter prefix", () => { + expect(parsePreSap("D55")).toEqual({ letter: "D", display: "D55" }); + }); + + it("derives letter for legacy numeric-only format", () => { + expect(parsePreSap("56")).toEqual({ letter: "D", display: "D56" }); + }); + + it("handles other EPC bands correctly", () => { + expect(parsePreSap("G10")).toEqual({ letter: "G", display: "G10" }); + expect(parsePreSap("A95")).toEqual({ letter: "A", display: "A95" }); + }); + + it("trims surrounding whitespace", () => { + expect(parsePreSap(" D55 ")).toEqual({ letter: "D", display: "D55" }); + }); + + it("normalises letter to uppercase", () => { + expect(parsePreSap("d55")).toEqual({ letter: "D", display: "D55" }); + }); +});