mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
updated install documents ui
This commit is contained in:
parent
a5b3cfe85b
commit
f04fb8f026
8 changed files with 327 additions and 125 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue