working on contractor upload modal

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-17 17:52:58 +00:00
parent 94027059e0
commit 3a4d102ea4
2 changed files with 306 additions and 55 deletions

View file

@ -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",
]);

View file

@ -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 (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder="Select type…" />
</SelectTrigger>
<SelectContent>
{FILE_TYPE_GROUPS.map((group) => {
const items = FILE_TYPE_OPTIONS.filter((o) => o.group === group);
if (!items.length) return null;
return (
<SelectGroup key={group}>
<SelectLabel className="text-[10px] text-gray-400 uppercase tracking-wide">{group}</SelectLabel>
{items.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
))}
</SelectGroup>
);
})}
</SelectContent>
</Select>
<div className="rounded-lg border border-blue-100 bg-blue-50/50">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-2.5 text-left"
>
<Info className="h-3.5 w-3.5 text-blue-500 shrink-0" />
<span className="text-xs font-medium text-blue-700 flex-1">
PAS 2030/2035 document requirements
</span>
{open
? <ChevronDown className="h-3.5 w-3.5 text-blue-400 shrink-0" />
: <ChevronRight className="h-3.5 w-3.5 text-blue-400 shrink-0" />
}
</button>
{open && (
<div className="px-3 pb-3 space-y-3 border-t border-blue-100">
{PAS_REQUIREMENTS.map((section) => (
<div key={section.heading}>
<p className="text-[10px] font-bold uppercase tracking-wide text-blue-600 mt-2.5 mb-1">
{section.heading}
</p>
<ul className="space-y-0.5">
{section.items.map((item) => (
<li key={item} className="flex items-start gap-1.5 text-xs text-blue-800">
<span className="mt-1 h-1 w-1 rounded-full bg-blue-400 shrink-0" />
{item}
</li>
))}
</ul>
</div>
))}
<p className="text-[10px] text-blue-500 mt-2 italic">
Insufficient mid-install photos will result in the Retrofit Coordinator sending back for re-submission and non-lodgement.
</p>
</div>
)}
</div>
);
}
// ── 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<React.CSSProperties>({});
const triggerRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div className="space-y-1">
{/* Trigger */}
<button
ref={triggerRef}
type="button"
onClick={() => open ? close() : openDropdown()}
className={`flex h-8 w-full items-center justify-between rounded-md border px-3 py-1 text-xs transition-colors
${open ? "border-brandblue ring-1 ring-brandblue/30" : "border-input hover:border-gray-300"}
bg-background`}
>
<span className={selected ? "text-gray-800" : "text-gray-400"}>
{selected ? selected.label : "Select type…"}
</span>
<ChevronDown className={`h-3.5 w-3.5 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && typeof document !== "undefined" && createPortal(
<>
{/* Transparent overlay — position:fixed escapes overflow:hidden, catches all outside clicks */}
<div className="fixed inset-0" style={{ zIndex: 9998 }} onClick={close} />
{/* Dropdown — above the overlay */}
<div style={dropdownStyle} className="rounded-md border border-gray-200 bg-white shadow-xl">
<div className="border-b border-gray-100 p-2">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => 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"
/>
</div>
<div className="max-h-56 overflow-y-auto py-1">
{filtered ? (
filtered.length === 0 ? (
<p className="px-3 py-2 text-xs text-gray-400">No results</p>
) : (
filtered.map((o) => (
<button
key={o.value}
type="button"
onClick={() => select(o.value)}
className={`flex w-full flex-col px-3 py-1.5 text-left text-xs hover:bg-brandlightblue/30 transition-colors
${value === o.value ? "bg-brandlightblue/20 font-medium text-brandblue" : "text-gray-700"}`}
>
<span>{o.label}</span>
<span className="text-[10px] text-gray-400">{o.group}</span>
</button>
))
)
) : (
FILE_TYPE_GROUPS.map((group) => {
const items = FILE_TYPE_OPTIONS.filter((o) => o.group === group);
if (!items.length) return null;
return (
<div key={group}>
<p className="px-3 pt-2 pb-0.5 text-[10px] font-semibold uppercase tracking-wide text-gray-400">
{group}
</p>
{items.map((o) => (
<button
key={o.value}
type="button"
onClick={() => select(o.value)}
className={`flex w-full px-3 py-1.5 text-left text-xs hover:bg-brandlightblue/30 transition-colors
${value === o.value ? "bg-brandlightblue/20 font-medium text-brandblue" : "text-gray-700"}`}
>
{o.label}
</button>
))}
</div>
);
})
)}
</div>
</div>
</>,
document.body
)}
{showHint && selected?.hint && (
<p className="text-[10px] text-blue-600 leading-snug px-0.5">{selected.hint}</p>
)}
</div>
);
}
@ -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 (
<Dialog open onOpenChange={onClose}>
<DialogContent className="sm:max-w-xl max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent className="sm:max-w-4xl max-h-[92vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>
{phase === "loading" ? "Loading…" :
@ -400,6 +630,9 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
{/* ── Phase 1: Upload ── */}
{phase === "upload" && (
<>
{/* PAS guidance */}
<PasGuidancePanel />
{/* Existing unclassified banner */}
{existingCount > 0 && (
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-amber-50 border border-amber-200 text-xs">
@ -463,15 +696,18 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
{/* ── Phase 2: Classify ── */}
{phase === "classify" && (
<div className="space-y-3">
{/* PAS guidance */}
<PasGuidancePanel />
{/* Column headers */}
<div className="grid grid-cols-[1fr_180px_128px] gap-2 px-1">
<div className="grid grid-cols-[1fr_260px_180px] gap-2 px-1">
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">File</span>
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Document Type <span className="text-red-400">*</span></span>
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide">Measure</span>
</div>
{classifiableEntries.map((entry) => (
<div key={entry.id} className="grid grid-cols-[1fr_180px_128px] gap-2 items-center px-1">
<div key={entry.id} className="grid grid-cols-[1fr_260px_180px] gap-2 items-center px-1">
<div className="flex items-center gap-2 min-w-0">
<StatusIcon status={entry.status} isExisting={!!entry.existingS3Key} />
<div className="min-w-0">
@ -480,14 +716,14 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
{entry.existingS3Key && <p className="text-[10px] text-amber-500">Previously uploaded</p>}
</div>
</div>
<DocTypeSelect value={entry.docType} onChange={(v) => updateEntryField(entry.id, "docType", v)} />
<DocTypeSelect value={entry.docType} onChange={(v) => updateEntryField(entry.id, "docType", v)} showHint />
{measures.length > 0 ? (
<Select value={entry.measureName} onValueChange={(v) => updateEntryField(entry.id, "measureName", v)}>
<Select value={entry.measureName || "__none__"} onValueChange={(v) => updateEntryField(entry.id, "measureName", v === "__none__" ? "" : v)}>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem value="" className="text-xs text-gray-400"> None </SelectItem>
<SelectItem value="__none__" className="text-xs text-gray-400"> None </SelectItem>
{measures.map((m) => (
<SelectItem key={m} value={m} className="text-xs">{m}</SelectItem>
))}