updated install documents ui

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-17 18:49:10 +00:00
parent a5b3cfe85b
commit f04fb8f026
8 changed files with 327 additions and 125 deletions

View file

@ -32,6 +32,7 @@ export async function GET(req: Request) {
source: uploadedFiles.source,
uprn: uploadedFiles.uprn,
landlordPropertyId: uploadedFiles.landlordPropertyId,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(condition);
@ -45,6 +46,7 @@ export async function GET(req: Request) {
s3UploadTimestamp: row.s3UploadTimestamp.toISOString(),
uprn: row.uprn !== null ? String(row.uprn) : null,
landlordPropertyId: row.landlordPropertyId,
measureName: row.measureName ?? null,
}));
return NextResponse.json(documents);

View file

@ -31,7 +31,8 @@ import { createDocumentTableColumns } from "./DocumentTableColumns";
import ContractorUploadModal from "./ContractorUploadModal";
import type { ClassifiedDeal, DocStatusMap, PortfolioCapabilityType } from "./types";
type SurveyStatusFilter = "all" | "none" | "partial" | "complete";
type RetroAssessmentFilter = "all" | "none" | "partial" | "complete";
type InstallStatusFilter = "all" | "none" | "hasDocs" | "partial" | "complete";
interface DocumentTableProps {
data: ClassifiedDeal[];
@ -54,7 +55,8 @@ function escapeCell(value: unknown): string {
export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability }: DocumentTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [surveyStatusFilter, setSurveyStatusFilter] = useState<SurveyStatusFilter>("all");
const [retroAssessmentFilter, setRetroAssessmentFilter] = useState<RetroAssessmentFilter>("all");
const [installStatusFilter, setInstallStatusFilter] = useState<InstallStatusFilter>("all");
const [sorting, setSorting] = useState<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
@ -63,15 +65,26 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
const [uploadDeal, setUploadDeal] = useState<ClassifiedDeal | null>(null);
const filteredData = useMemo(() => {
if (surveyStatusFilter === "all") return data;
return data.filter((d) => {
const status = d.uprn ? docStatusMap[d.uprn] : undefined;
if (surveyStatusFilter === "none") return !status || !status.hasDocs;
if (surveyStatusFilter === "partial") return !!status?.hasDocs && !status.isComplete;
if (surveyStatusFilter === "complete") return !!status?.isComplete;
if (retroAssessmentFilter !== "all") {
if (retroAssessmentFilter === "none" && !(!status || !status.hasSurveyDocs)) return false;
if (retroAssessmentFilter === "partial" && !(status?.hasSurveyDocs && !status.isSurveyComplete)) return false;
if (retroAssessmentFilter === "complete" && !status?.isSurveyComplete) return false;
}
if (installStatusFilter !== "all") {
const s = status?.installStatus ?? "none";
if (installStatusFilter === "none" && s !== "none") return false;
if (installStatusFilter === "hasDocs" && s !== "hasDocs") return false;
if (installStatusFilter === "partial" && s !== "partial") return false;
if (installStatusFilter === "complete" && s !== "all") return false;
}
return true;
});
}, [data, surveyStatusFilter, docStatusMap]);
}, [data, retroAssessmentFilter, installStatusFilter, docStatusMap]);
const columns = useMemo(
() => createDocumentTableColumns(
@ -98,19 +111,27 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
const downloadCsv = () => {
const rows = table.getFilteredRowModel().rows;
const header = "Address,Landlord ID,Survey Status";
const header = "Address,Landlord ID,Retrofit Assessment Status,Install Docs Status";
const body = rows
.map((row) => {
const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
const surveyStatus = status?.isComplete
const retroStatus = status?.isSurveyComplete
? "Complete"
: status?.hasDocs
: status?.hasSurveyDocs
? "Partial"
: "No Docs";
const installStatusMap: Record<string, string> = {
all: "All Measures",
partial: "Some Measures",
hasDocs: "Has Docs",
none: "No Docs",
};
const installStatus = installStatusMap[status?.installStatus ?? "none"];
return [
escapeCell(row.original.dealname),
escapeCell(row.original.landlordPropertyId),
surveyStatus,
retroStatus,
installStatus,
].join(",");
})
.join("\n");
@ -127,11 +148,19 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
const currentPage = table.getState().pagination.pageIndex + 1;
const totalFiltered = table.getFilteredRowModel().rows.length;
const surveyStatusLabel: Record<SurveyStatusFilter, string> = {
all: "All statuses",
none: "No Survey Docs",
partial: "Partial Survey Docs",
complete: "Complete Survey Docs",
const retroAssessmentLabel: Record<RetroAssessmentFilter, string> = {
all: "All retrofit statuses",
none: "No Retrofit Docs",
partial: "Partial Retrofit Docs",
complete: "Complete Retrofit Docs",
};
const installStatusLabel: Record<InstallStatusFilter, string> = {
all: "All install statuses",
none: "No Install Docs",
hasDocs: "Has Install Docs",
partial: "Some Measures",
complete: "All Measures",
};
return (
@ -152,22 +181,42 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
/>
</div>
{/* Survey status filter */}
{/* Retrofit assessment filter */}
<Select
value={surveyStatusFilter}
value={retroAssessmentFilter}
onValueChange={(v) => {
setSurveyStatusFilter(v as SurveyStatusFilter);
setRetroAssessmentFilter(v as RetroAssessmentFilter);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
<SelectTrigger className="h-9 w-[200px] text-sm border-gray-200 shrink-0">
{surveyStatusLabel[surveyStatusFilter]}
<SelectTrigger className="h-9 w-[210px] text-sm border-gray-200 shrink-0">
{retroAssessmentLabel[retroAssessmentFilter]}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="none">No Survey Docs</SelectItem>
<SelectItem value="partial">Partial Survey Docs</SelectItem>
<SelectItem value="complete">Complete Survey Docs</SelectItem>
<SelectItem value="all">All retrofit statuses</SelectItem>
<SelectItem value="none">No Retrofit Docs</SelectItem>
<SelectItem value="partial">Partial Retrofit Docs</SelectItem>
<SelectItem value="complete">Complete Retrofit Docs</SelectItem>
</SelectContent>
</Select>
{/* Install docs filter */}
<Select
value={installStatusFilter}
onValueChange={(v) => {
setInstallStatusFilter(v as InstallStatusFilter);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
<SelectTrigger className="h-9 w-[190px] text-sm border-gray-200 shrink-0">
{installStatusLabel[installStatusFilter]}
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All install statuses</SelectItem>
<SelectItem value="none">No Install Docs</SelectItem>
<SelectItem value="hasDocs">Has Install Docs</SelectItem>
<SelectItem value="partial">Some Measures</SelectItem>
<SelectItem value="complete">All Measures</SelectItem>
</SelectContent>
</Select>
@ -192,7 +241,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
</span>{" "}
of{" "}
<span className="font-semibold text-gray-600">{totalFiltered}</span>{" "}
{surveyStatusFilter !== "all" ? `(${surveyStatusLabel[surveyStatusFilter].toLowerCase()}) ` : ""}
{(retroAssessmentFilter !== "all" || installStatusFilter !== "all") ? "(filtered) " : ""}
propert{totalFiltered === 1 ? "y" : "ies"}
</p>

View file

@ -1,7 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX, Upload } from "lucide-react";
import { ArrowUpDown, CheckCircle2, AlertCircle, FileX, Upload, Package } from "lucide-react";
import type { ClassifiedDeal, DocStatusMap, DocStatus } from "./types";
function SortableHeader({
@ -22,8 +22,8 @@ function SortableHeader({
);
}
function SurveyStatusBadge({ status }: { status: DocStatus | undefined }) {
if (status?.isComplete) {
function RetroAssessmentBadge({ status }: { status: DocStatus | undefined }) {
if (status?.isSurveyComplete) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-emerald-50 text-emerald-700 border-emerald-200">
<CheckCircle2 className="h-3.5 w-3.5" />
@ -31,7 +31,7 @@ function SurveyStatusBadge({ status }: { status: DocStatus | undefined }) {
</span>
);
}
if (status?.hasDocs) {
if (status?.hasSurveyDocs) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-amber-50 text-amber-700 border-amber-200">
<AlertCircle className="h-3.5 w-3.5" />
@ -47,6 +47,40 @@ function SurveyStatusBadge({ status }: { status: DocStatus | undefined }) {
);
}
function InstallDocsBadge({ status }: { status: DocStatus | undefined }) {
const installStatus = status?.installStatus ?? "none";
if (installStatus === "all") {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-emerald-50 text-emerald-700 border-emerald-200">
<CheckCircle2 className="h-3.5 w-3.5" />
All Measures
</span>
);
}
if (installStatus === "partial") {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-amber-50 text-amber-700 border-amber-200">
<AlertCircle className="h-3.5 w-3.5" />
Some Measures
</span>
);
}
if (installStatus === "hasDocs") {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-sky-50 text-sky-700 border-sky-200">
<Package className="h-3.5 w-3.5" />
Has Docs
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border whitespace-nowrap bg-gray-50 text-gray-400 border-gray-200">
<FileX className="h-3.5 w-3.5" />
No Docs
</span>
);
}
export function createDocumentTableColumns(
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
docStatusMap: DocStatusMap = {},
@ -81,19 +115,38 @@ export function createDocumentTableColumns(
enableHiding: false,
},
// ── Survey Status ─────────────────────────────────────────────────────
// ── Retrofit Assessment Docs Status ───────────────────────────────────
{
id: "surveyStatus",
id: "retroAssessmentStatus",
accessorFn: (row) => {
const status = row.uprn ? docStatusMap[row.uprn] : undefined;
if (status?.isComplete) return 2;
if (status?.hasDocs) return 1;
if (status?.isSurveyComplete) return 2;
if (status?.hasSurveyDocs) return 1;
return 0;
},
header: ({ column }) => <SortableHeader label="Survey Status" column={column as any} />,
header: ({ column }) => <SortableHeader label="Retrofit Assessment Docs" column={column as any} />,
cell: ({ row }) => {
const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
return <SurveyStatusBadge status={status} />;
return <RetroAssessmentBadge status={status} />;
},
enableHiding: false,
},
// ── Install Docs Status ───────────────────────────────────────────────
{
id: "installDocs",
accessorFn: (row) => {
const status = row.uprn ? docStatusMap[row.uprn] : undefined;
const s = status?.installStatus ?? "none";
if (s === "all") return 3;
if (s === "partial") return 2;
if (s === "hasDocs") return 1;
return 0;
},
header: ({ column }) => <SortableHeader label="Install Docs" column={column as any} />,
cell: ({ row }) => {
const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
return <InstallDocsBadge status={status} />;
},
enableHiding: false,
},
@ -111,11 +164,11 @@ export function createDocumentTableColumns(
let icon: React.ReactNode;
let className: string;
if (status?.isComplete) {
if (status?.isSurveyComplete) {
icon = <CheckCircle2 className="h-3.5 w-3.5" />;
className =
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-emerald-200 text-emerald-700 bg-emerald-50 hover:bg-emerald-100 hover:border-emerald-300 transition-all duration-150 whitespace-nowrap";
} else if (status?.hasDocs) {
} else if (status?.hasSurveyDocs) {
icon = <AlertCircle className="h-3.5 w-3.5" />;
className =
"inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-amber-200 text-amber-700 bg-amber-50 hover:bg-amber-100 hover:border-amber-300 transition-all duration-150 whitespace-nowrap";

View file

@ -11,6 +11,7 @@ import {
FolderOpen,
X,
ExternalLink,
HardHat,
} from "lucide-react";
import {
Drawer,
@ -21,10 +22,11 @@ import {
DrawerDescription,
} from "@/app/shadcn_components/ui/drawer";
import type { PropertyDocument } from "./types";
import { EXPECTED_SURVEY_DOC_TYPES } from "./types";
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
// Human-readable labels for the main DB fileType enum values
// 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",
@ -34,13 +36,43 @@ const DOC_TYPE_LABELS: Record<string, string> = {
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",
};
// All survey docs go under this group for now (extensible later)
function getDocCategory(_docType: string): string {
return "Survey Documents";
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("en-GB", {
@ -56,7 +88,7 @@ function formatDate(iso: string): string {
// -----------------------------------------------------------------------
// Individual document row
// -----------------------------------------------------------------------
function DocumentRow({ doc }: { doc: PropertyDocument }) {
function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) {
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
const { mutate: download, isPending: signing } = useMutation({
@ -90,7 +122,10 @@ function DocumentRow({ doc }: { doc: PropertyDocument }) {
<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">
{formatDate(doc.s3UploadTimestamp)}
{showMeasure && doc.measureName
? <><span className="text-brandblue/70 font-medium">{doc.measureName}</span> · {formatDate(doc.s3UploadTimestamp)}</>
: formatDate(doc.s3UploadTimestamp)
}
</p>
</div>
</div>
@ -161,20 +196,16 @@ export default function PropertyDrawer({
}
const documents = open ? (fetchedDocuments as PropertyDocument[]) : lastDocumentsRef.current;
// Group docs by category for display
const grouped = documents.reduce<
Record<string, PropertyDocument[]>
>((acc, doc) => {
const category = getDocCategory(doc.docType);
(acc[category] ??= []).push(doc);
return acc;
}, {});
// 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;
const presentTypes = new Set(documents.map((d) => d.docType));
const missingTypes = EXPECTED_SURVEY_DOC_TYPES.filter(
(t) => !presentTypes.has(t),
// 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 (
@ -220,7 +251,7 @@ export default function PropertyDrawer({
</DrawerHeader>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{/* Loading state */}
{isFetching && (
<div className="space-y-3 pt-2">
@ -248,7 +279,7 @@ export default function PropertyDrawer({
</div>
)}
{/* Empty state — shows all missing doc types */}
{/* Empty state */}
{!isFetching && !isError && !hasDocuments && (
<div className="space-y-4 pt-1">
<div className="flex flex-col items-center py-6 text-center">
@ -259,15 +290,14 @@ export default function PropertyDrawer({
No documents available
</p>
<p className="text-xs text-gray-400 mt-1">
All {EXPECTED_SURVEY_DOC_TYPES.length} survey documents are
outstanding.
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 ({missingTypes.length})
Missing Documents ({missingRetrofitTypes.length})
</h3>
{missingTypes.map((t) => (
{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"
@ -282,58 +312,74 @@ export default function PropertyDrawer({
</div>
)}
{/* Document groups */}
<AnimatePresence>
{!isFetching &&
!isError &&
hasDocuments &&
Object.entries(grouped).map(([category, docs]) => (
{!isFetching && !isError && hasDocuments && (
<>
{/* ── Retrofit Assessment Documents ── */}
<motion.div
key={category}
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">
{category}
Retrofit Assessment Documents
</h3>
<div className="space-y-1.5">
{docs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} />
))}
</div>
</motion.div>
))}
</AnimatePresence>
{/* Missing documents section — shown when some but not all docs are present */}
{!isFetching &&
!isError &&
hasDocuments &&
missingTypes.length > 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-2"
>
<h3 className="text-xs font-semibold uppercase tracking-wide text-amber-500 px-0.5">
Missing Documents ({missingTypes.length})
</h3>
<div className="space-y-1.5">
{missingTypes.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>
{retrofitDocs.length > 0 ? (
<div className="space-y-1.5">
{retrofitDocs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} />
))}
</div>
))}
</div>
</motion.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-2"
>
<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>
{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>
</div>
{/* Footer */}

View file

@ -126,9 +126,9 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
if (docFilter !== "all") {
result = result.filter((d) => {
const status = d.uprn ? docStatusMap[d.uprn] : undefined;
if (docFilter === "none") return !status || !status.hasDocs;
if (docFilter === "has_docs") return !!status?.hasDocs;
if (docFilter === "incomplete") return !!status?.hasDocs && !status.isComplete;
if (docFilter === "none") return !status || !status.hasSurveyDocs;
if (docFilter === "has_docs") return !!status?.hasSurveyDocs;
if (docFilter === "incomplete") return !!status?.hasSurveyDocs && !status.isSurveyComplete;
return true;
});
}

View file

@ -285,8 +285,8 @@ export function createPropertyTableColumns(
cell: ({ row }) => {
const uprn = row.original.uprn ?? "";
const status = uprn ? docStatusMap[uprn] : undefined;
const isComplete = status?.isComplete;
const hasDocs = status?.hasDocs;
const isComplete = status?.isSurveyComplete;
const hasDocs = status?.hasSurveyDocs;
let icon: React.ReactNode;
let className: string;

View file

@ -13,7 +13,7 @@ import { portfolioCapabilities } from "@/app/db/schema/portfolio";
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
import { user as userTable } from "@/app/db/schema/users";
import type { HubspotDeal, DocStatusMap, DocStatus, PortfolioCapabilityType, ApprovalsByDeal } from "./types";
import { EXPECTED_SURVEY_DOC_TYPES } from "./types";
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
import type { InferSelectModel } from "drizzle-orm";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { Building2 } from "lucide-react";
@ -184,23 +184,58 @@ export default async function LiveReportingPage(props: {
if (uprnList.length > 0) {
const docRows = await db
.select()
.select({
uprn: uploadedFiles.uprn,
fileType: uploadedFiles.fileType,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(inArray(uploadedFiles.uprn, uprnList));
const grouped: Record<string, Set<string>> = {};
// Group docs by UPRN
const docsByUprn = new Map<string, Array<{ fileType: string; measureName: string | null }>>();
for (const row of docRows) {
if (row.uprn === null || row.fileType === null) continue;
const key = String(row.uprn);
(grouped[key] ??= new Set()).add(row.fileType);
if (!docsByUprn.has(key)) docsByUprn.set(key, []);
docsByUprn.get(key)!.push({ fileType: row.fileType, measureName: row.measureName });
}
for (const [uprn, types] of Object.entries(grouped)) {
const presentTypes = Array.from(types);
// Build measures lookup from deals (uprn → proposed measure names)
const measuresByUprn = new Map<string, string[]>();
for (const deal of deals) {
if (deal.uprn) {
const key = String(deal.uprn);
const measures = (deal.proposedMeasures ?? "")
.split(",").map((m: string) => m.trim()).filter(Boolean);
measuresByUprn.set(key, measures);
}
}
for (const [uprn, docs] of docsByUprn) {
const surveyDocs = docs.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.fileType));
const installDocs = docs.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.fileType));
const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType));
const measures = measuresByUprn.get(uprn) ?? [];
let installStatus: DocStatus["installStatus"] = "none";
if (installDocs.length > 0) {
if (measures.length === 0) {
installStatus = "hasDocs";
} else {
const measuresWithDocs = new Set(
installDocs.map((d) => d.measureName).filter(Boolean),
);
installStatus = measures.every((m) => measuresWithDocs.has(m)) ? "all" : "partial";
}
}
const status: DocStatus = {
presentTypes,
hasDocs: presentTypes.length > 0,
isComplete: EXPECTED_SURVEY_DOC_TYPES.every((t) => types.has(t)),
presentSurveyTypes: Array.from(surveyTypeSet),
hasSurveyDocs: surveyDocs.length > 0,
isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) => surveyTypeSet.has(t)),
hasInstallDocs: installDocs.length > 0,
installStatus,
};
docStatusMap[uprn] = status;
}

View file

@ -205,10 +205,11 @@ export type PropertyDocument = {
s3UploadTimestamp: string; // ISO string
uprn: string | null;
landlordPropertyId: string | null;
measureName: string | null; // set for install docs
};
// All survey document types expected for a complete survey
export const EXPECTED_SURVEY_DOC_TYPES = [
// Mandatory retrofit assessment doc types (used for completeness check — ecmk types are optional)
export const EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES = [
"photo_pack",
"site_note",
"rd_sap_site_note",
@ -220,10 +221,26 @@ export const EXPECTED_SURVEY_DOC_TYPES = [
"pas_2023_occupancy",
] as const;
// All survey-adjacent types (including optional ecmk docs) — used for display categorisation
export const SURVEY_ALL_DOC_TYPES = new Set<string>([
...EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES,
"ecmk_site_note",
"ecmk_rd_sap_site_note",
"ecmk_survey_xml",
]);
export type DocStatus = {
presentTypes: string[];
hasDocs: boolean;
isComplete: boolean; // all EXPECTED_SURVEY_DOC_TYPES present
// Retrofit assessment docs
presentSurveyTypes: string[];
hasSurveyDocs: boolean;
isSurveyComplete: boolean; // all 9 EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES present (ecmk not counted)
// Install docs
hasInstallDocs: boolean;
installStatus: "none" | "partial" | "hasDocs" | "all";
// "all" = install docs exist for every proposed measure
// "partial" = some (but not all) proposed measures have docs
// "hasDocs" = has install docs but no measures defined on the deal
// "none" = no install docs at all
};
export type DocStatusMap = Record<string, DocStatus>; // keyed by UPRN string