mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
moving the documents ui to the deal page view
This commit is contained in:
parent
5154b665ef
commit
e2b7bc9d93
6 changed files with 643 additions and 454 deletions
|
|
@ -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<string, string> = {
|
||||
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 (
|
||||
<button
|
||||
onClick={() => download()}
|
||||
disabled={signing}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brandblue text-white text-xs font-medium hover:bg-brandblue/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{signing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<FileDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{signing ? "Preparing…" : "Download"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) {
|
||||
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center justify-between gap-4 px-4 py-3 rounded-lg border border-gray-100 bg-white hover:border-brandblue/20 hover:shadow-sm transition-all duration-150"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="shrink-0 w-8 h-8 rounded-lg bg-sky-50 border border-sky-200 flex items-center justify-center">
|
||||
<FileText className="h-4 w-4 text-sky-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800 truncate">{label}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{showMeasure && doc.measureName ? (
|
||||
<>
|
||||
<span className="text-brandblue/70 font-medium">{doc.measureName}</span>{" "}
|
||||
· {formatDocDate(doc.s3UploadTimestamp)}
|
||||
</>
|
||||
) : (
|
||||
formatDocDate(doc.s3UploadTimestamp)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DownloadDocButton doc={doc} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{/* Upload button */}
|
||||
{canUpload && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setUploadOpen(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brandblue text-white text-sm font-semibold hover:bg-brandmidblue transition-colors"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload Docs
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{isFetching && (
|
||||
<div className="space-y-3 pt-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-14 rounded-lg bg-gray-100 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{isError && !isFetching && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mb-3">
|
||||
<ExternalLink className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-700">Could not load documents</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Please try again later.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty */}
|
||||
{!isFetching && !isError && !hasDocuments && (
|
||||
<div className="space-y-4 pt-1">
|
||||
<div className="flex flex-col items-center py-6 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center mb-3">
|
||||
<FolderOpen className="h-6 w-6 text-amber-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-700">No documents available</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
All {EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.length} retrofit assessment documents are outstanding.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-amber-500 px-0.5">
|
||||
Missing Documents ({missingRetrofitTypes.length})
|
||||
</h3>
|
||||
{missingRetrofitTypes.map((t) => (
|
||||
<div
|
||||
key={t}
|
||||
className="flex items-center gap-2.5 p-3 rounded-lg border border-dashed border-amber-200 bg-amber-50/40"
|
||||
>
|
||||
<FileX className="h-3.5 w-3.5 text-amber-300 shrink-0" />
|
||||
<span className="text-xs text-amber-600 font-medium">
|
||||
{DOC_TYPE_LABELS[t] ?? t}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents */}
|
||||
<AnimatePresence>
|
||||
{!isFetching && !isError && hasDocuments && (
|
||||
<>
|
||||
{/* Retrofit Assessment */}
|
||||
<motion.div key="retrofit" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5">
|
||||
Retrofit Assessment Documents
|
||||
</h3>
|
||||
{retrofitDocs.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{retrofitDocs.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 px-0.5">None uploaded yet.</p>
|
||||
)}
|
||||
{missingRetrofitTypes.length > 0 && (
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-amber-500 px-0.5">
|
||||
Missing ({missingRetrofitTypes.length})
|
||||
</h4>
|
||||
{missingRetrofitTypes.map((t) => (
|
||||
<div
|
||||
key={t}
|
||||
className="flex items-center gap-2.5 p-3 rounded-lg border border-dashed border-amber-200 bg-amber-50/40"
|
||||
>
|
||||
<FileX className="h-3.5 w-3.5 text-amber-300 shrink-0" />
|
||||
<span className="text-xs text-amber-600 font-medium">
|
||||
{DOC_TYPE_LABELS[t] ?? t}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Install Documents */}
|
||||
<motion.div key="install" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5 flex items-center gap-1.5">
|
||||
<HardHat className="h-3.5 w-3.5" />
|
||||
Install Documents
|
||||
</h3>
|
||||
|
||||
{docStatus.measureProgress.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{docStatus.measureProgress.map((mp) => {
|
||||
const measureDocs = installDocs.filter((d) => d.measureName === mp.measureName);
|
||||
const uploadedTypeSet = new Set(measureDocs.map((d) => d.docType));
|
||||
const missingTypes = mp.required.filter((t) => !uploadedTypeSet.has(t));
|
||||
|
||||
return (
|
||||
<div key={mp.measureName} className="rounded-xl border border-gray-100 bg-gray-50/40 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2.5 border-b border-gray-100 bg-white">
|
||||
<span className="text-xs font-semibold text-gray-800">{mp.measureName}</span>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border ${
|
||||
mp.isComplete
|
||||
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||
: mp.uploadedCount > 0
|
||||
? "bg-amber-50 text-amber-700 border-amber-200"
|
||||
: "bg-gray-100 text-gray-500 border-gray-200"
|
||||
}`}>
|
||||
{mp.uploadedCount} / {mp.requiredCount} docs
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3 py-2.5 space-y-1.5">
|
||||
{mp.uploaded.map((docType) => {
|
||||
const doc = measureDocs.find((d) => d.docType === docType);
|
||||
if (!doc) return null;
|
||||
return (
|
||||
<div key={docType} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-emerald-100 bg-emerald-50/50">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="shrink-0 w-5 h-5 rounded-full bg-emerald-100 border border-emerald-200 flex items-center justify-center">
|
||||
<svg className="h-3 w-3 text-emerald-600" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[docType] ?? docType}</p>
|
||||
<p className="text-[10px] text-gray-400">{formatDocDate(doc.s3UploadTimestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DownloadDocButton doc={doc} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{missingTypes.map((docType) => (
|
||||
<div key={docType} className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-dashed border-amber-200 bg-amber-50/30">
|
||||
<div className="shrink-0 w-5 h-5 rounded-full border-2 border-dashed border-amber-300 flex items-center justify-center">
|
||||
<FileX className="h-2.5 w-2.5 text-amber-400" />
|
||||
</div>
|
||||
<p className="text-xs text-amber-700 font-medium">{DOC_TYPE_LABELS[docType] ?? docType}</p>
|
||||
</div>
|
||||
))}
|
||||
{measureDocs
|
||||
.filter((d) => !mp.required.includes(d.docType))
|
||||
.map((doc) => (
|
||||
<div key={doc.id} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-gray-100 bg-white">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="shrink-0 w-5 h-5 rounded-full bg-sky-50 border border-sky-200 flex items-center justify-center">
|
||||
<FileText className="h-3 w-3 text-sky-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[doc.docType] ?? doc.docType}</p>
|
||||
<p className="text-[10px] text-gray-400">{formatDocDate(doc.s3UploadTimestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DownloadDocButton doc={doc} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Unassigned install docs */}
|
||||
{(() => {
|
||||
const unassigned = getUnassignedInstallDocs(installDocs, docStatus.measureProgress);
|
||||
if (unassigned.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 px-0.5">Other</h4>
|
||||
{unassigned.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} showMeasure />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : installDocs.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{installDocs.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} showMeasure />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 px-0.5">No install documents uploaded yet.</p>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Upload modal */}
|
||||
{canUpload && uploadOpen && deal && portfolioId && (
|
||||
<ContractorUploadModal
|
||||
deal={deal}
|
||||
portfolioId={portfolioId}
|
||||
onClose={() => setUploadOpen(false)}
|
||||
docStatusMap={docStatusMap}
|
||||
approvedMeasures={approvedMeasures}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, string> = {
|
||||
// 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 (
|
||||
<button
|
||||
onClick={() => download()}
|
||||
disabled={signing}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brandblue text-white text-xs font-medium hover:bg-brandblue/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{signing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<FileDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{signing ? "Preparing…" : "Download"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Individual document row — used in retrofit section and install fallback
|
||||
// -----------------------------------------------------------------------
|
||||
function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) {
|
||||
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center justify-between gap-4 px-4 py-3 rounded-lg border border-gray-100 bg-white hover:border-brandblue/20 hover:shadow-sm transition-all duration-150"
|
||||
>
|
||||
{/* Left: icon + label + date stacked */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="shrink-0 w-8 h-8 rounded-lg bg-sky-50 border border-sky-200 flex items-center justify-center">
|
||||
<FileText className="h-4 w-4 text-sky-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-800 truncate">{label}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{showMeasure && doc.measureName
|
||||
? <><span className="text-brandblue/70 font-medium">{doc.measureName}</span> · {formatDate(doc.s3UploadTimestamp)}</>
|
||||
: formatDate(doc.s3UploadTimestamp)
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DownloadDocButton doc={doc} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
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<PropertyDocument[]>;
|
||||
},
|
||||
|
|
@ -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<PropertyDocument[]>([]);
|
||||
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 (
|
||||
<Drawer open={open} onOpenChange={(v) => !v && onClose()} direction="right">
|
||||
<DrawerContent className="fixed right-0 top-0 bottom-0 h-full w-[40vw] min-w-80 rounded-l-2xl rounded-r-none mt-0 flex flex-col border-l border-t-0 border-b-0 border-r-0 border-brandblue/10 bg-white shadow-2xl overflow-hidden">
|
||||
{/* Remove the default drag handle */}
|
||||
<div className="hidden" />
|
||||
|
||||
<DrawerHeader className="shrink-0 px-6 pt-6 pb-4 border-b border-gray-100">
|
||||
|
|
@ -253,7 +96,7 @@ export default function PropertyDrawer({
|
|||
</DrawerClose>
|
||||
</div>
|
||||
|
||||
{hasDocuments && !isFetching && (
|
||||
{documents.length > 0 && !isFetching && (
|
||||
<div className="mt-3 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-brandblue/10 border border-brandblue/20">
|
||||
<FileDown className="h-3.5 w-3.5 text-brandblue" />
|
||||
<span className="text-xs font-medium text-brandblue">
|
||||
|
|
@ -263,242 +106,18 @@ export default function PropertyDrawer({
|
|||
)}
|
||||
</DrawerHeader>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
{/* Loading state */}
|
||||
{isFetching && (
|
||||
<div className="space-y-3 pt-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-14 rounded-lg bg-gray-100 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{isError && !isFetching && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center mb-3">
|
||||
<ExternalLink className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
Could not load documents
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isFetching && !isError && !hasDocuments && (
|
||||
<div className="space-y-4 pt-1">
|
||||
<div className="flex flex-col items-center py-6 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-amber-50 border border-amber-200 flex items-center justify-center mb-3">
|
||||
<FolderOpen className="h-6 w-6 text-amber-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
No documents available
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
All {EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.length} retrofit assessment documents are outstanding.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-amber-500 px-0.5">
|
||||
Missing Documents ({missingRetrofitTypes.length})
|
||||
</h3>
|
||||
{missingRetrofitTypes.map((t) => (
|
||||
<div
|
||||
key={t}
|
||||
className="flex items-center gap-2.5 p-3 rounded-lg border border-dashed border-amber-200 bg-amber-50/40"
|
||||
>
|
||||
<FileX className="h-3.5 w-3.5 text-amber-300 shrink-0" />
|
||||
<span className="text-xs text-amber-600 font-medium">
|
||||
{DOC_TYPE_LABELS[t] ?? t}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{!isFetching && !isError && hasDocuments && (
|
||||
<>
|
||||
{/* ── Retrofit Assessment Documents ── */}
|
||||
<motion.div
|
||||
key="retrofit"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5">
|
||||
Retrofit Assessment Documents
|
||||
</h3>
|
||||
{retrofitDocs.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{retrofitDocs.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 px-0.5">None uploaded yet.</p>
|
||||
)}
|
||||
|
||||
{/* Missing mandatory retrofit assessment docs */}
|
||||
{missingRetrofitTypes.length > 0 && (
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-amber-500 px-0.5">
|
||||
Missing ({missingRetrofitTypes.length})
|
||||
</h4>
|
||||
{missingRetrofitTypes.map((t) => (
|
||||
<div
|
||||
key={t}
|
||||
className="flex items-center gap-2.5 p-3 rounded-lg border border-dashed border-amber-200 bg-amber-50/40"
|
||||
>
|
||||
<FileX className="h-3.5 w-3.5 text-amber-300 shrink-0" />
|
||||
<span className="text-xs text-amber-600 font-medium">
|
||||
{DOC_TYPE_LABELS[t] ?? t}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* ── Install Documents ── */}
|
||||
<motion.div
|
||||
key="install"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5 flex items-center gap-1.5">
|
||||
<HardHat className="h-3.5 w-3.5" />
|
||||
Install Documents
|
||||
</h3>
|
||||
|
||||
{docStatus?.measureProgress && docStatus.measureProgress.length > 0 ? (
|
||||
// ── Per-measure checklist ──
|
||||
<div className="space-y-4">
|
||||
{docStatus.measureProgress.map((mp) => {
|
||||
const measureDocs = installDocs.filter((d) => d.measureName === mp.measureName);
|
||||
const uploadedTypeSet = new Set(measureDocs.map((d) => d.docType));
|
||||
const missingTypes = mp.required.filter((t) => !uploadedTypeSet.has(t));
|
||||
|
||||
return (
|
||||
<div key={mp.measureName} className="rounded-xl border border-gray-100 bg-gray-50/40 overflow-hidden">
|
||||
{/* Measure header */}
|
||||
<div className="flex items-center justify-between px-3 py-2.5 border-b border-gray-100 bg-white">
|
||||
<span className="text-xs font-semibold text-gray-800">{mp.measureName}</span>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border ${
|
||||
mp.isComplete
|
||||
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||
: mp.uploadedCount > 0
|
||||
? "bg-amber-50 text-amber-700 border-amber-200"
|
||||
: "bg-gray-100 text-gray-500 border-gray-200"
|
||||
}`}>
|
||||
{mp.uploadedCount} / {mp.requiredCount} docs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2.5 space-y-1.5">
|
||||
{/* Uploaded required docs */}
|
||||
{mp.uploaded.map((docType) => {
|
||||
const doc = measureDocs.find((d) => d.docType === docType);
|
||||
if (!doc) return null;
|
||||
return (
|
||||
<div key={docType} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-emerald-100 bg-emerald-50/50">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="shrink-0 w-5 h-5 rounded-full bg-emerald-100 border border-emerald-200 flex items-center justify-center">
|
||||
<svg className="h-3 w-3 text-emerald-600" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[docType] ?? docType}</p>
|
||||
<p className="text-[10px] text-gray-400">{formatDate(doc.s3UploadTimestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DownloadDocButton doc={doc} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Missing required docs */}
|
||||
{missingTypes.map((docType) => (
|
||||
<div key={docType} className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-dashed border-amber-200 bg-amber-50/30">
|
||||
<div className="shrink-0 w-5 h-5 rounded-full border-2 border-dashed border-amber-300 flex items-center justify-center">
|
||||
<FileX className="h-2.5 w-2.5 text-amber-400" />
|
||||
</div>
|
||||
<p className="text-xs text-amber-700 font-medium">{DOC_TYPE_LABELS[docType] ?? docType}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Extra docs uploaded for this measure (not in required list) */}
|
||||
{measureDocs
|
||||
.filter((d) => !mp.required.includes(d.docType))
|
||||
.map((doc) => (
|
||||
<div key={doc.id} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-gray-100 bg-white">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="shrink-0 w-5 h-5 rounded-full bg-sky-50 border border-sky-200 flex items-center justify-center">
|
||||
<FileText className="h-3 w-3 text-sky-500" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[doc.docType] ?? doc.docType}</p>
|
||||
<p className="text-[10px] text-gray-400">{formatDate(doc.s3UploadTimestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DownloadDocButton doc={doc} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Unassigned / no-measure install docs */}
|
||||
{(() => {
|
||||
const knownMeasures = new Set(docStatus.measureProgress.map((m) => m.measureName));
|
||||
const unassigned = installDocs.filter(
|
||||
(d) => !d.measureName || !knownMeasures.has(d.measureName),
|
||||
);
|
||||
if (unassigned.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 px-0.5">Other</h4>
|
||||
{unassigned.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} showMeasure />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : installDocs.length > 0 ? (
|
||||
// ── Fallback: flat list (no measure progress data) ──
|
||||
<div className="space-y-1.5">
|
||||
{installDocs.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} showMeasure />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 px-0.5">No install documents uploaded yet.</p>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<PropertyDocumentsContent
|
||||
documents={documents}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
docStatus={docStatus ?? { presentSurveyTypes: [], hasSurveyDocs: false, isSurveyComplete: false, hasInstallDocs: false, installStatus: "none", measureProgress: [] }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-gray-100 bg-gray-50/50">
|
||||
<p className="text-xs text-gray-400">
|
||||
Download links expire after 30 minutes. Refresh to generate a new
|
||||
link.
|
||||
Download links expire after 30 minutes. Refresh to generate a new link.
|
||||
</p>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
|
|
|
|||
|
|
@ -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<PropertyDocument[]>;
|
||||
},
|
||||
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 ── */}
|
||||
<div
|
||||
className={`p-5 space-y-4 ${activeTab === "documents" ? "block" : "hidden"}`}
|
||||
className={`p-5 ${activeTab === "documents" ? "block" : "hidden"}`}
|
||||
>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400">
|
||||
Documents
|
||||
</h3>
|
||||
{docStatus.hasSurveyDocs || docStatus.hasInstallDocs ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${docStatus.isSurveyComplete ? "bg-emerald-500" : docStatus.hasSurveyDocs ? "bg-amber-400" : "bg-gray-300"}`}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Survey docs:{" "}
|
||||
<span className="font-medium">
|
||||
{docStatus.isSurveyComplete
|
||||
? "Complete"
|
||||
: docStatus.hasSurveyDocs
|
||||
? `${docStatus.presentSurveyTypes.length} uploaded`
|
||||
: "None"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${docStatus.installStatus === "all" ? "bg-emerald-500" : docStatus.installStatus === "partial" || docStatus.installStatus === "hasDocs" ? "bg-amber-400" : "bg-gray-300"}`}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
Install docs:{" "}
|
||||
<span className="font-medium capitalize">
|
||||
{docStatus.installStatus === "none"
|
||||
? "None"
|
||||
: docStatus.installStatus === "all"
|
||||
? "Complete"
|
||||
: "Partial"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{docStatus.measureProgress.length > 0 && (
|
||||
<div className="space-y-2 mt-2">
|
||||
{docStatus.measureProgress.map((mp) => (
|
||||
<div
|
||||
key={mp.measureName}
|
||||
className="flex items-center justify-between text-xs py-1.5 border-b border-gray-50 last:border-0"
|
||||
>
|
||||
<span className="text-gray-700">{mp.measureName}</span>
|
||||
<span
|
||||
className={`font-medium ${mp.isComplete ? "text-emerald-600" : "text-amber-600"}`}
|
||||
>
|
||||
{mp.uploadedCount}/{mp.requiredCount}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">No documents uploaded yet.</p>
|
||||
)}
|
||||
<PropertyDocumentsContent
|
||||
documents={documents}
|
||||
isFetching={docsFetching}
|
||||
isError={docsError}
|
||||
docStatus={docStatus}
|
||||
deal={deal}
|
||||
portfolioId={portfolioId}
|
||||
userCapability={userCapability}
|
||||
approvedMeasures={approvedMeasures}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
}
|
||||
37
src/app/utils.test.ts
Normal file
37
src/app/utils.test.ts
Normal file
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue