From 3a4d102ea45107d6658e65d44255beb6ab7a546f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 17 Apr 2026 17:52:58 +0000 Subject: [PATCH 1/5] working on contractor upload modal --- src/app/db/schema/uploaded_files.ts | 29 +- .../live/ContractorUploadModal.tsx | 332 +++++++++++++++--- 2 files changed, 306 insertions(+), 55 deletions(-) diff --git a/src/app/db/schema/uploaded_files.ts b/src/app/db/schema/uploaded_files.ts index abf2fa1..cc5141c 100644 --- a/src/app/db/schema/uploaded_files.ts +++ b/src/app/db/schema/uploaded_files.ts @@ -16,22 +16,37 @@ export const fileType = pgEnum("file_type", [ "ecmk_rd_sap_site_note", "ecmk_survey_xml", // Contractor install documentation + // Photos "pre_photo", "mid_photo", "post_photo", + "loft_hatch_photo", + "dmev_photos", + "door_undercut_photos", + "trickle_vent_photos", + // Pre-installation "pre_installation_building_inspection", + "point_of_work_risk_assessment", + // Compliance & lodgement "claim_of_compliance", + "mcs_compliance_certificate", + "certificate_of_conformity", + "minor_works_electrical_certificate", + "trustmark_licence_numbers", + "operative_competency", + // Ventilation + "ventilation_assessment_checklist", + "anemometer_readings", + "commissioning_records", + "part_f_ventilation_document", + // Handover & warranties "handover_pack", "insurance_guarantee", - "installer_qualifications", - "mcs_compliance_certificate", - "minor_works_electrical_certificate", - "point_of_work_risk_assessment", - "installer_feedback", "workmanship_warranty", "g98_notification", - "certificate_of_conformity", - "ventilation_assessment_checklist", + // Qualifications & other + "installer_qualifications", + "installer_feedback", "contractor_other", ]); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx index 0d6de22..eb6b66d 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { Dialog, DialogContent, @@ -19,7 +20,7 @@ import { SelectTrigger, SelectValue, } from "@/app/shadcn_components/ui/select"; -import { CheckCircle2, XCircle, Upload, Loader2, Clock } from "lucide-react"; +import { CheckCircle2, XCircle, Upload, Loader2, Clock, ChevronDown, ChevronRight, Info } from "lucide-react"; import { uploadFileToS3 } from "@/app/utils/s3"; import type { ClassifiedDeal } from "./types"; @@ -54,27 +55,97 @@ type Props = { // ── Constants ───────────────────────────────────────────────────────────── -const FILE_TYPE_OPTIONS: { value: string; label: string; group: string }[] = [ - { value: "pre_photo", label: "Pre Photo", group: "Install Photos" }, - { value: "mid_photo", label: "Mid Photo", group: "Install Photos" }, - { value: "post_photo", label: "Post Photo", group: "Install Photos" }, - { value: "pre_installation_building_inspection", label: "Pre-Installation Building Inspection (PIBI)", group: "Pre-Installation" }, - { value: "point_of_work_risk_assessment", label: "Point of Work Risk Assessment", group: "Pre-Installation" }, - { value: "claim_of_compliance", label: "Claim of Compliance (PAS 2030)", group: "Compliance" }, - { value: "mcs_compliance_certificate", label: "MCS Compliance Certificate", group: "Compliance" }, - { value: "certificate_of_conformity", label: "Certificate of Conformity", group: "Compliance" }, - { value: "minor_works_electrical_certificate", label: "Minor Works Electrical Certificate", group: "Compliance" }, - { value: "handover_pack", label: "Handover Documents / Pack", group: "Handover" }, - { value: "workmanship_warranty", label: "Workmanship Warranty", group: "Handover" }, - { value: "insurance_guarantee", label: "Insurance Backed Guarantee (IBG)", group: "Handover" }, - { value: "g98_notification", label: "G98 / G99 Notification", group: "Handover" }, - { value: "ventilation_assessment_checklist", label: "Ventilation Assessment Checklist", group: "Handover" }, - { value: "installer_qualifications", label: "Installer Qualifications", group: "Qualifications" }, - { value: "installer_feedback", label: "Installer Feedback", group: "Other" }, - { value: "contractor_other", label: "Other", group: "Other" }, +const FILE_TYPE_OPTIONS: { value: string; label: string; group: string; hint?: string }[] = [ + // Photos + { value: "pre_photo", label: "Pre-Install Photos", group: "Photos", hint: "Required for ALL measures. Capture existing condition before any work begins." }, + { value: "mid_photo", label: "Mid-Install Photos", group: "Photos", hint: "Required for ALL measures. Detailed photos showing all angles and areas during installation. Insufficient pictures will result in non-lodgement." }, + { value: "post_photo", label: "Post-Install Photos", group: "Photos", hint: "Required for ALL measures. Confirm completed installation." }, + { value: "loft_hatch_photo", label: "Loft Hatch & Draft Excluder Photos",group: "Photos", hint: "Required for loft insulation. Must show loft hatch insulation, draft excluders, and hook & eye closing. Also include photos of insulation depth with a ruler showing thickness." }, + { value: "dmev_photos", label: "DMEV Photos (Wetrooms)", group: "Photos", hint: "Clear photos of all Decentralised Mechanical Extract Ventilation units installed in wetrooms." }, + { value: "door_undercut_photos",label: "Door Undercut Photos", group: "Photos", hint: "Photos of all door undercuts to demonstrate compliant ventilation paths." }, + { value: "trickle_vent_photos", label: "Trickle Vent Photos", group: "Photos", hint: "Photos of all trickle vents located in windows." }, + // Pre-installation + { value: "pre_installation_building_inspection", label: "PIBI / Tech Survey", group: "Pre-Installation", hint: "Pre-Installation Building Inspection — required per property and per measure." }, + { value: "point_of_work_risk_assessment", label: "Point of Work Risk Assessment", group: "Pre-Installation" }, + // Compliance & lodgement + { value: "claim_of_compliance", label: "DOCC 2030 (Claim of Compliance)", group: "Compliance & Lodgement", hint: "Required per property and per measure for TrustMark lodgement under PAS 2030." }, + { value: "mcs_compliance_certificate", label: "MCS Compliance Certificate", group: "Compliance & Lodgement", hint: "Required for Solar PV and Air Source Heat Pump installations." }, + { value: "certificate_of_conformity", label: "Certificate of Conformity", group: "Compliance & Lodgement" }, + { value: "minor_works_electrical_certificate", label: "Minor Works Electrical Certificate", group: "Compliance & Lodgement" }, + { value: "trustmark_licence_numbers", label: "TrustMark Licence Numbers", group: "Compliance & Lodgement", hint: "All installer and subcontractor TrustMark licence numbers. Ensure all are accredited for the correct measures under PAS 2023." }, + { value: "operative_competency", label: "Operative Competency", group: "Compliance & Lodgement", hint: "PAS 2030 installer accreditation and qualifications of individual workers, suitable for the measure(s) installed. Verify all installers/subcontractors are accredited under PAS 2023." }, + // Ventilation + { value: "ventilation_assessment_checklist", label: "Ventilation Assessment Checklist", group: "Ventilation" }, + { value: "anemometer_readings", label: "Anemometer Readings", group: "Ventilation", hint: "Required for DMEV/ventilation measures to confirm airflow compliance." }, + { value: "commissioning_records", label: "Commissioning Records", group: "Ventilation", hint: "Tests, certifications and commissioning records for all systems installed." }, + { value: "part_f_ventilation_document", label: "Approved Document Part F", group: "Ventilation", hint: "Ventilation compliance document under Approved Document Part F." }, + // Handover & warranties + { value: "handover_pack", label: "Handover Pack", group: "Handover & Warranties" }, + { value: "workmanship_warranty", label: "Workmanship Warranty", group: "Handover & Warranties", hint: "Required per property and per measure for TrustMark lodgement." }, + { value: "insurance_guarantee", label: "Insurance Backed Guarantee (IBG)", group: "Handover & Warranties", hint: "Required per property and per measure for TrustMark lodgement." }, + { value: "g98_notification", label: "G98 / G99 Notification", group: "Handover & Warranties", hint: "Required for Solar PV and other grid-connected installations." }, + // Qualifications & other + { value: "installer_qualifications", label: "Installer Qualifications", group: "Qualifications & Other" }, + { value: "installer_feedback", label: "Installer Feedback", group: "Qualifications & Other" }, + { value: "contractor_other", label: "Other", group: "Qualifications & Other" }, ]; -const FILE_TYPE_GROUPS = ["Install Photos", "Pre-Installation", "Compliance", "Handover", "Qualifications", "Other"]; +const FILE_TYPE_GROUPS = [ + "Photos", + "Pre-Installation", + "Compliance & Lodgement", + "Ventilation", + "Handover & Warranties", + "Qualifications & Other", +]; + +// ── PAS 2030/2035 requirements summary (for guidance panel) ─────────────── + +const PAS_REQUIREMENTS = [ + { + heading: "Required for every property & measure", + items: [ + "PIBI / Tech Survey (pre-installation building inspection)", + "DOCC 2030 — Claim of Compliance (PAS 2030)", + "Insurance Backed Guarantee (IBG)", + "Workmanship Warranty", + "Pre, mid, and post-install photos (all measures)", + ], + }, + { + heading: "Additional for Solar PV & ASHP", + items: [ + "MCS Compliance Certificate", + "G98 / G99 Notification", + ], + }, + { + heading: "Loft insulation", + items: [ + "Loft hatch insulation, draft excluders, and hook & eye closing photos", + "Photos of insulation depth with a ruler showing thickness", + ], + }, + { + heading: "Ventilation measures", + items: [ + "Clear DMEV photos in all wetrooms", + "Anemometer readings", + "Commissioning records", + "Approved Document Part F ventilation document", + "Door undercut photos", + "Trickle vent photos", + ], + }, + { + heading: "Installer / lodgement pack", + items: [ + "TrustMark licence numbers for all installers & subcontractors (verify PAS 2023 accreditation)", + "Operative Competency (PAS 2030 accreditation + individual worker qualifications)", + "Minor Works Electrical Certificate (where applicable)", + ], + }, +]; // ── Helpers ─────────────────────────────────────────────────────────────── @@ -139,29 +210,188 @@ async function saveClassifications( if (!res.ok) throw new Error("Failed to save classifications"); } -// ── DocType select ───────────────────────────────────────────────────────── +// ── PAS guidance panel ──────────────────────────────────────────────────── -function DocTypeSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) { +function PasGuidancePanel() { + const [open, setOpen] = useState(false); return ( - +
+ + {open && ( +
+ {PAS_REQUIREMENTS.map((section) => ( +
+

+ {section.heading} +

+
    + {section.items.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+ ))} +

+ Insufficient mid-install photos will result in the Retrofit Coordinator sending back for re-submission and non-lodgement. +

+
+ )} +
+ ); +} + +// ── Searchable DocType combobox ──────────────────────────────────────────── + +function DocTypeSelect({ value, onChange, showHint = false }: { value: string; onChange: (v: string) => void; showHint?: boolean }) { + const selected = FILE_TYPE_OPTIONS.find((o) => o.value === value); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [dropdownStyle, setDropdownStyle] = useState({}); + const triggerRef = useRef(null); + const inputRef = useRef(null); + + function openDropdown() { + if (!triggerRef.current) return; + const rect = triggerRef.current.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + const dropdownHeight = 280; + const showAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight; + setDropdownStyle({ + position: "fixed", + left: rect.left, + width: rect.width, + zIndex: 9999, + ...(showAbove + ? { bottom: window.innerHeight - rect.top + 4 } + : { top: rect.bottom + 4 }), + }); + setOpen(true); + } + + function close() { + setOpen(false); + setQuery(""); + } + + function select(val: string) { + onChange(val); + close(); + } + + // Focus search input when opening + useEffect(() => { + if (open) setTimeout(() => inputRef.current?.focus(), 0); + }, [open]); + + const filtered = query.trim() + ? FILE_TYPE_OPTIONS.filter((o) => + o.label.toLowerCase().includes(query.toLowerCase()) || + o.group.toLowerCase().includes(query.toLowerCase()) + ) + : null; + + return ( +
+ {/* Trigger */} + + + {open && typeof document !== "undefined" && createPortal( + <> + {/* Transparent overlay — position:fixed escapes overflow:hidden, catches all outside clicks */} +
+ + {/* Dropdown — above the overlay */} +
+
+ setQuery(e.target.value)} + placeholder="Search…" + className="w-full rounded border border-gray-200 px-2 py-1 text-xs outline-none focus:border-brandblue" + /> +
+
+ {filtered ? ( + filtered.length === 0 ? ( +

No results

+ ) : ( + filtered.map((o) => ( + + )) + ) + ) : ( + FILE_TYPE_GROUPS.map((group) => { + const items = FILE_TYPE_OPTIONS.filter((o) => o.group === group); + if (!items.length) return null; + return ( +
+

+ {group} +

+ {items.map((o) => ( + + ))} +
+ ); + }) + )} +
+
+ , + document.body + )} + + {showHint && selected?.hint && ( +

{selected.hint}

+ )} +
); } @@ -343,7 +573,7 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr classifiableEntries.map((f) => ({ id: f.uploadedId!, fileType: f.docType, - measureName: f.measureName || undefined, + measureName: (f.measureName && f.measureName !== "__none__") ? f.measureName : undefined, })), ); onClose(); @@ -364,7 +594,7 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr return ( - + {phase === "loading" ? "Loading…" : @@ -400,6 +630,9 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr {/* ── Phase 1: Upload ── */} {phase === "upload" && ( <> + {/* PAS guidance */} + + {/* Existing unclassified banner */} {existingCount > 0 && (
@@ -463,15 +696,18 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr {/* ── Phase 2: Classify ── */} {phase === "classify" && (
+ {/* PAS guidance */} + + {/* Column headers */} -
+
File Document Type * Measure
{classifiableEntries.map((entry) => ( -
+
@@ -480,14 +716,14 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr {entry.existingS3Key &&

Previously uploaded

}
- updateEntryField(entry.id, "docType", v)} /> + updateEntryField(entry.id, "docType", v)} showHint /> {measures.length > 0 ? ( - updateEntryField(entry.id, "measureName", v === "__none__" ? "" : v)}> - — None — + — None — {measures.map((m) => ( {m} ))} From a5b3cfe85bddd562478ea276820f8c65d315df0e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 17 Apr 2026 18:18:07 +0000 Subject: [PATCH 2/5] set the contractor upload and classification workflow --- .../live/ContractorUploadModal.tsx | 152 +++--------------- 1 file changed, 25 insertions(+), 127 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx index eb6b66d..1fa07b7 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; import { Dialog, DialogContent, @@ -256,138 +255,37 @@ function PasGuidancePanel() { ); } -// ── Searchable DocType combobox ──────────────────────────────────────────── +// ── DocType select ──────────────────────────────────────────────────────── function DocTypeSelect({ value, onChange, showHint = false }: { value: string; onChange: (v: string) => void; showHint?: boolean }) { const selected = FILE_TYPE_OPTIONS.find((o) => o.value === value); - const [open, setOpen] = useState(false); - const [query, setQuery] = useState(""); - const [dropdownStyle, setDropdownStyle] = useState({}); - const triggerRef = useRef(null); - const inputRef = useRef(null); - - function openDropdown() { - if (!triggerRef.current) return; - const rect = triggerRef.current.getBoundingClientRect(); - const spaceBelow = window.innerHeight - rect.bottom; - const dropdownHeight = 280; - const showAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight; - setDropdownStyle({ - position: "fixed", - left: rect.left, - width: rect.width, - zIndex: 9999, - ...(showAbove - ? { bottom: window.innerHeight - rect.top + 4 } - : { top: rect.bottom + 4 }), - }); - setOpen(true); - } - - function close() { - setOpen(false); - setQuery(""); - } - - function select(val: string) { - onChange(val); - close(); - } - - // Focus search input when opening - useEffect(() => { - if (open) setTimeout(() => inputRef.current?.focus(), 0); - }, [open]); - - const filtered = query.trim() - ? FILE_TYPE_OPTIONS.filter((o) => - o.label.toLowerCase().includes(query.toLowerCase()) || - o.group.toLowerCase().includes(query.toLowerCase()) - ) - : null; return (
- {/* Trigger */} - - - {open && typeof document !== "undefined" && createPortal( - <> - {/* Transparent overlay — position:fixed escapes overflow:hidden, catches all outside clicks */} -
- - {/* Dropdown — above the overlay */} -
-
- setQuery(e.target.value)} - placeholder="Search…" - className="w-full rounded border border-gray-200 px-2 py-1 text-xs outline-none focus:border-brandblue" - /> -
-
- {filtered ? ( - filtered.length === 0 ? ( -

No results

- ) : ( - filtered.map((o) => ( - - )) - ) - ) : ( - FILE_TYPE_GROUPS.map((group) => { - const items = FILE_TYPE_OPTIONS.filter((o) => o.group === group); - if (!items.length) return null; - return ( -
-

- {group} -

- {items.map((o) => ( - - ))} -
- ); - }) - )} -
-
- , - document.body - )} - + {showHint && selected?.hint && (

{selected.hint}

)} From f04fb8f0265fd15dae9adcc330906a997d246cb1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 17 Apr 2026 18:49:10 +0000 Subject: [PATCH 3/5] updated install documents ui --- .../live-tracking/property-documents/route.ts | 2 + .../your-projects/live/DocumentTable.tsx | 101 +++++++--- .../live/DocumentTableColumns.tsx | 77 ++++++-- .../your-projects/live/PropertyDrawer.tsx | 182 +++++++++++------- .../your-projects/live/PropertyTable.tsx | 6 +- .../live/PropertyTableColumns.tsx | 4 +- .../(portfolio)/your-projects/live/page.tsx | 53 ++++- .../(portfolio)/your-projects/live/types.ts | 27 ++- 8 files changed, 327 insertions(+), 125 deletions(-) diff --git a/src/app/api/live-tracking/property-documents/route.ts b/src/app/api/live-tracking/property-documents/route.ts index 0e73dc6..270d826 100644 --- a/src/app/api/live-tracking/property-documents/route.ts +++ b/src/app/api/live-tracking/property-documents/route.ts @@ -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); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx index 195b534..01baed9 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTable.tsx @@ -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("all"); + const [retroAssessmentFilter, setRetroAssessmentFilter] = useState("all"); + const [installStatusFilter, setInstallStatusFilter] = useState("all"); const [sorting, setSorting] = useState([]); const [pagination, setPagination] = useState({ pageIndex: 0, @@ -63,15 +65,26 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo const [uploadDeal, setUploadDeal] = useState(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 = { + 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 = { - all: "All statuses", - none: "No Survey Docs", - partial: "Partial Survey Docs", - complete: "Complete Survey Docs", + const retroAssessmentLabel: Record = { + all: "All retrofit statuses", + none: "No Retrofit Docs", + partial: "Partial Retrofit Docs", + complete: "Complete Retrofit Docs", + }; + + const installStatusLabel: Record = { + 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 />
- {/* Survey status filter */} + {/* Retrofit assessment filter */} + + {/* Install docs filter */} + @@ -192,7 +241,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo {" "} of{" "} {totalFiltered}{" "} - {surveyStatusFilter !== "all" ? `(${surveyStatusLabel[surveyStatusFilter].toLowerCase()}) ` : ""} + {(retroAssessmentFilter !== "all" || installStatusFilter !== "all") ? "(filtered) " : ""} propert{totalFiltered === 1 ? "y" : "ies"}

diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx index 1b762c9..d630130 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/DocumentTableColumns.tsx @@ -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 ( @@ -31,7 +31,7 @@ function SurveyStatusBadge({ status }: { status: DocStatus | undefined }) { ); } - if (status?.hasDocs) { + if (status?.hasSurveyDocs) { return ( @@ -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 ( + + + All Measures + + ); + } + if (installStatus === "partial") { + return ( + + + Some Measures + + ); + } + if (installStatus === "hasDocs") { + return ( + + + Has Docs + + ); + } + return ( + + + No Docs + + ); +} + 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 }) => , + header: ({ column }) => , cell: ({ row }) => { const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined; - return ; + return ; + }, + 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 }) => , + cell: ({ row }) => { + const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined; + return ; }, enableHiding: false, }, @@ -111,11 +164,11 @@ export function createDocumentTableColumns( let icon: React.ReactNode; let className: string; - if (status?.isComplete) { + if (status?.isSurveyComplete) { icon = ; 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 = ; 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"; diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx index 6dc83ef..63ef131 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDrawer.tsx @@ -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 = { + // 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 = { 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 }) {

{label}

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

@@ -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 - >((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({ {/* Body */} -
+
{/* Loading state */} {isFetching && (
@@ -248,7 +279,7 @@ export default function PropertyDrawer({
)} - {/* Empty state — shows all missing doc types */} + {/* Empty state */} {!isFetching && !isError && !hasDocuments && (
@@ -259,15 +290,14 @@ export default function PropertyDrawer({ No documents available

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

- Missing Documents ({missingTypes.length}) + Missing Documents ({missingRetrofitTypes.length})

- {missingTypes.map((t) => ( + {missingRetrofitTypes.map((t) => (
)} - {/* Document groups */} - {!isFetching && - !isError && - hasDocuments && - Object.entries(grouped).map(([category, docs]) => ( + {!isFetching && !isError && hasDocuments && ( + <> + {/* ── Retrofit Assessment Documents ── */}

- {category} + Retrofit Assessment Documents

-
- {docs.map((doc) => ( - - ))} -
-
- ))} -
- - {/* Missing documents section — shown when some but not all docs are present */} - {!isFetching && - !isError && - hasDocuments && - missingTypes.length > 0 && ( - -

- Missing Documents ({missingTypes.length}) -

-
- {missingTypes.map((t) => ( -
- - - {DOC_TYPE_LABELS[t] ?? t} - + {retrofitDocs.length > 0 ? ( +
+ {retrofitDocs.map((doc) => ( + + ))}
- ))} -
- + ) : ( +

None uploaded yet.

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

+ Missing ({missingRetrofitTypes.length}) +

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

+ + Install Documents +

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

No install documents uploaded yet.

+ )} +
+ )} +
{/* Footer */} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx index ac29be0..db9c867 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTable.tsx @@ -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; }); } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx index 9562418..03804ee 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyTableColumns.tsx @@ -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; diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx index 0cbc869..db5a3ae 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx @@ -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> = {}; + // Group docs by UPRN + const docsByUprn = new Map>(); 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(); + 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; } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts index a244f68..749a97d 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts @@ -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([ + ...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; // keyed by UPRN string From ffe79a27e4b51197f14b2559aefe0492aaad2f9d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 17 Apr 2026 19:24:05 +0000 Subject: [PATCH 4/5] adding removal requests --- .../your-projects/live/LiveTracker.tsx | 5 + .../live/PropertyDetailDrawer.tsx | 278 +++++++++++++++++- .../(portfolio)/your-projects/live/page.tsx | 28 +- .../(portfolio)/your-projects/live/types.ts | 16 + 4 files changed, 320 insertions(+), 7 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx index 2bb2a4f..0725568 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx @@ -34,6 +34,8 @@ export default function LiveTracker({ userCapability, approvalsByDeal, portfolioId, + userRole, + userEmail, }: LiveTrackerProps) { // ── Tab state ──────────────────────────────────────────────────────── const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents" | "measures">( @@ -360,6 +362,9 @@ export default function LiveTracker({ deal={detailDeal} portfolioId={portfolioId} onClose={() => setDetailDeal(null)} + userRole={userRole} + userCapability={userCapability} + userEmail={userEmail} />
); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx index 0ca08ce..71175b6 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -1,19 +1,263 @@ "use client"; import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { motion, AnimatePresence } from "framer-motion"; -import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown } from "lucide-react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2 } from "lucide-react"; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, - DrawerDescription, } from "@/app/shadcn_components/ui/drawer"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/app/shadcn_components/ui/dialog"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/app/shadcn_components/ui/tooltip"; import { STAGE_COLORS } from "./types"; -import type { ClassifiedDeal } from "./types"; +import type { ClassifiedDeal, PortfolioCapabilityType, RemovalRequest } from "./types"; + +// ----------------------------------------------------------------------- +// Removal request section +// ----------------------------------------------------------------------- +const WRITE_ROLES = ["creator", "admin", "write"]; + +function RemovalRequestSection({ + dealId, + portfolioId, + userRole, + userCapability, +}: { + dealId: string; + portfolioId: string; + userRole: string; + userCapability: PortfolioCapabilityType; +}) { + const queryClient = useQueryClient(); + const [dialogOpen, setDialogOpen] = useState(false); + const [reason, setReason] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [reviewing, setReviewing] = useState(false); + const [error, setError] = useState(null); + + const canRequest = WRITE_ROLES.includes(userRole); + const isApprover = userCapability.includes("approver"); + + const { data, isLoading } = useQuery<{ requests: RemovalRequest[] }>({ + queryKey: ["removalRequests", portfolioId, dealId], + queryFn: async () => { + const res = await fetch( + `/api/portfolio/${portfolioId}/removal-requests?dealId=${dealId}`, + ); + if (!res.ok) throw new Error("Failed to fetch removal requests"); + return res.json(); + }, + staleTime: 30_000, + }); + + const pendingRequest = data?.requests?.find((r) => r.status === "pending") ?? null; + const latestResolvedRequest = data?.requests?.find((r) => r.status !== "pending") ?? null; + + async function handleSubmit() { + if (!reason.trim()) return; + setSubmitting(true); + setError(null); + try { + const res = await fetch(`/api/portfolio/${portfolioId}/removal-requests`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hubspotDealId: dealId, reason: reason.trim() }), + }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + setError(json.error ?? "Failed to submit request"); + return; + } + setDialogOpen(false); + setReason(""); + queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] }); + } finally { + setSubmitting(false); + } + } + + async function handleReview(requestId: string, action: "approved" | "declined") { + setReviewing(true); + setError(null); + try { + const res = await fetch(`/api/portfolio/${portfolioId}/removal-requests`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requestId: Number(requestId), action }), + }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + setError(json.error ?? "Failed to review request"); + return; + } + queryClient.invalidateQueries({ queryKey: ["removalRequests", portfolioId, dealId] }); + } finally { + setReviewing(false); + } + } + + if (isLoading) { + return

Loading…

; + } + + return ( +
+ {error && ( +

{error}

+ )} + + {/* Pending request — visible to everyone */} + {pendingRequest && ( +
+
+ + Pending Removal Request + +
+

{pendingRequest.reason}

+

+ Requested by {pendingRequest.requestedByEmail} + {" · "} + {formatDateTime(pendingRequest.requestedAt)} +

+ {/* Approver actions */} + {isApprover && ( +
+ + +
+ )} +
+ )} + + {/* Most recent resolved request */} + {!pendingRequest && latestResolvedRequest && ( +
+
+ + {latestResolvedRequest.status === "approved" ? "Removal Approved" : "Removal Declined"} + +
+

{latestResolvedRequest.reason}

+

+ Requested by {latestResolvedRequest.requestedByEmail} + {" · "} + {formatDateTime(latestResolvedRequest.requestedAt)} +

+ {latestResolvedRequest.reviewedByEmail && ( +

+ {latestResolvedRequest.status === "approved" ? "Approved" : "Declined"} by{" "} + {latestResolvedRequest.reviewedByEmail} + {latestResolvedRequest.reviewedAt && ` · ${formatDateTime(latestResolvedRequest.reviewedAt)}`} +

+ )} +
+ )} + + {/* Request button — only shown when no pending request exists */} + {!pendingRequest && ( + + + + + + + + {!canRequest && ( + + Not available with read-only permissions + + )} + + + )} + + {/* Reason dialog */} + { if (!v) { setDialogOpen(false); setReason(""); setError(null); } }}> + + + + Request Removal from Project + + +
+

+ Please provide a reason why this property should be removed from the project. This will be recorded for audit purposes. +

+