moving the documents ui to the deal page view

This commit is contained in:
Khalim Conn-Kowlessar 2026-05-08 10:45:40 +00:00
parent 5154b665ef
commit e2b7bc9d93
6 changed files with 643 additions and 454 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View 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" });
});
});