Merge pull request #235 from Hestia-Homes/feature/installer-interaction

Feature/installer interaction
This commit is contained in:
KhalimCK 2026-04-21 14:57:38 +01:00 committed by GitHub
commit 216b984140
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 876 additions and 172 deletions

View file

@ -5,22 +5,25 @@ import { uploadedFiles } from "@/app/db/schema/uploaded_files";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const dealIdParam = searchParams.get("dealId");
const uprnParam = searchParams.get("uprn");
const landlordPropertyIdParam = searchParams.get("landlordPropertyId");
if (!uprnParam && !landlordPropertyIdParam) {
if (!dealIdParam && !uprnParam && !landlordPropertyIdParam) {
return NextResponse.json(
{ error: "uprn or landlordPropertyId is required" },
{ error: "dealId, uprn, or landlordPropertyId is required" },
{ status: 400 },
);
}
try {
// Prefer UPRN — it's more selective and avoids an OR full-table scan.
// Only fall back to landlordPropertyId when no UPRN is available.
const condition = uprnParam
? eq(uploadedFiles.uprn, BigInt(uprnParam))
: eq(uploadedFiles.landlordPropertyId, landlordPropertyIdParam!);
// Prefer dealId — reliable even when UPRN is missing from the deal.
// Fall back to UPRN, then landlordPropertyId.
const condition = dealIdParam
? eq(uploadedFiles.hubsotDealId, dealIdParam)
: uprnParam
? eq(uploadedFiles.uprn, BigInt(uprnParam))
: eq(uploadedFiles.landlordPropertyId, landlordPropertyIdParam!);
const rows = await db
.select({

View file

@ -7,6 +7,26 @@ import { z } from "zod";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { syncContractorDocUploadToHubSpot } from "@/app/lib/hubspot/dealSync";
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
function computeMeasureProgress(
proposedMeasures: string[],
dealDocs: { fileType: string | null; measureName: string | null }[],
) {
const installDocs = dealDocs.filter((d) => d.fileType !== null && d.measureName !== null);
return proposedMeasures.map((measureName) => {
const required = getRequiredDocs(measureName);
const docsForMeasure = installDocs.filter((d) => d.measureName === measureName);
const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType));
const uploadedCount = required.filter((r) => uploadedTypeSet.has(r)).length;
return {
measureName,
uploadedCount,
requiredCount: required.length,
isComplete: uploadedCount === required.length,
};
});
}
// POST — record a contractor install document in uploaded_files (fileType optional — can be classified later)
export async function POST(req: NextRequest) {
@ -85,6 +105,8 @@ export async function PATCH(req: NextRequest) {
measureName: z.string().optional(),
}),
),
hubspotDealId: z.string().optional(),
proposedMeasures: z.array(z.string()).optional(),
});
let body: z.infer<typeof bodySchema>;
@ -110,6 +132,17 @@ export async function PATCH(req: NextRequest) {
.where(eq(uploadedFiles.id, BigInt(update.id)));
}
// Sync per-measure progress to HubSpot after classification
if (body.hubspotDealId && body.proposedMeasures && body.proposedMeasures.length > 0) {
const dealDocs = await db
.select({ fileType: uploadedFiles.fileType, measureName: uploadedFiles.measureName })
.from(uploadedFiles)
.where(eq(uploadedFiles.hubsotDealId, body.hubspotDealId));
const measureProgress = computeMeasureProgress(body.proposedMeasures, dealDocs);
void syncContractorDocUploadToHubSpot({ hubspotDealId: body.hubspotDealId, measureProgress });
}
return NextResponse.json({ success: true });
} catch (err) {
console.error("PATCH /upload/contractor-install error:", err);

View file

@ -71,16 +71,43 @@ export async function syncRemovalRequestToHubSpot(params: {
}
}
type MeasureUploadProgress = {
measureName: string;
uploadedCount: number;
requiredCount: number;
isComplete: boolean;
};
export async function syncContractorDocUploadToHubSpot(params: {
hubspotDealId: string;
measureProgress?: MeasureUploadProgress[];
}): Promise<void> {
let log: string;
let uploadStatus: string;
if (params.measureProgress && params.measureProgress.length > 0) {
log = params.measureProgress
.map((m) => {
if (m.isComplete) return `${m.measureName}: Complete (${m.uploadedCount}/${m.requiredCount} docs)`;
if (m.uploadedCount > 0) return `${m.measureName}: In Progress (${m.uploadedCount}/${m.requiredCount} docs)`;
return `${m.measureName}: Not Started (0/${m.requiredCount} docs)`;
})
.join(" | ");
uploadStatus = params.measureProgress.every((m) => m.isComplete)
? "Upload Complete for All Measures"
: "Upload in progress";
} else {
log = "Documents available - uploaded by contractor";
uploadStatus = "Upload in progress";
}
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const client = getHubSpotClient();
await client.crm.deals.basicApi.update(params.hubspotDealId, {
properties: {
contractor_document_upload_log: "Documents available - uploaded by contractor",
contractor_document_upload_log: log,
contractor_document_upload_status: uploadStatus,
},
});
return;

View file

@ -0,0 +1,81 @@
/**
* Measure Document Requirements
* Maps HubSpot measure names to the required installer document types (fileType enum values).
* Used to compute per-measure upload completion and guide contractors in the upload modal.
*/
// Required for every measure
const BASE_DOCS = [
"pre_photo",
"mid_photo",
"post_photo",
"pre_installation_building_inspection",
"claim_of_compliance",
"insurance_guarantee",
"workmanship_warranty",
] as const;
// MCS-accredited measures require MCS certification in addition to base docs
const MCS_EXTRA = ["mcs_compliance_certificate"] as const;
export const MEASURE_DOC_REQUIREMENTS: Record<string, string[]> = {
ASHP: [...BASE_DOCS, ...MCS_EXTRA, "commissioning_records"],
"Solar PV": [...BASE_DOCS, ...MCS_EXTRA, "g98_notification"],
DMevs: [
...BASE_DOCS,
"dmev_photos",
"anemometer_readings",
"commissioning_records",
"part_f_ventilation_document",
"door_undercut_photos",
"trickle_vent_photos",
"ventilation_assessment_checklist",
"minor_works_electrical_certificate",
],
"Loft insulation": [...BASE_DOCS, "loft_hatch_photo"],
// All remaining measures require BASE_DOCS only:
// CWI, EWI, IWI, "Flat roof", RIR, UFI, HW, Windows, "Ext. doors",
// TRVs, "Heating controls", "New boiler", HHRSH, Battery, LEL,
// "Listed building", "Removal 2nd heating", Others
};
/**
* Returns the required document types for a given measure name.
* Falls back to BASE_DOCS for any measure not explicitly listed.
*/
export function getRequiredDocs(measureName: string): string[] {
return MEASURE_DOC_REQUIREMENTS[measureName] ?? [...BASE_DOCS];
}
/**
* Human-readable label for a fileType enum value.
* Matches the labels used in ContractorUploadModal FILE_TYPE_OPTIONS.
*/
export const FILE_TYPE_LABELS: Record<string, string> = {
pre_photo: "Pre-Install Photos",
mid_photo: "Mid-Install Photos",
post_photo: "Post-Install Photos",
loft_hatch_photo: "Loft Hatch & Draft Excluder Photos",
dmev_photos: "DMEV Photos (Wetrooms)",
door_undercut_photos: "Door Undercut Photos",
trickle_vent_photos: "Trickle Vent Photos",
pre_installation_building_inspection: "PIBI / Tech Survey",
point_of_work_risk_assessment: "Point of Work Risk Assessment",
claim_of_compliance: "DOCC 2030 (Claim of Compliance)",
mcs_compliance_certificate: "MCS Compliance Certificate",
certificate_of_conformity: "Certificate of Conformity",
minor_works_electrical_certificate: "Minor Works Electrical Certificate",
trustmark_licence_numbers: "TrustMark Licence Numbers",
operative_competency: "Operative Competency",
ventilation_assessment_checklist: "Ventilation Assessment Checklist",
anemometer_readings: "Anemometer Readings",
commissioning_records: "Commissioning Records",
part_f_ventilation_document: "Approved Document Part F",
handover_pack: "Handover Pack",
workmanship_warranty: "Workmanship Warranty",
insurance_guarantee: "Insurance Backed Guarantee (IBG)",
g98_notification: "G98 / G99 Notification",
installer_qualifications: "Installer Qualifications",
installer_feedback: "Installer Feedback",
contractor_other: "Other",
};

View file

@ -21,7 +21,8 @@ import {
} from "@/app/shadcn_components/ui/select";
import { CheckCircle2, XCircle, Upload, Loader2, Clock, ChevronDown, ChevronRight, Info } from "lucide-react";
import { uploadFileToS3 } from "@/app/utils/s3";
import type { ClassifiedDeal } from "./types";
import type { ClassifiedDeal, DocStatusMap } from "./types";
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
// ── Types ─────────────────────────────────────────────────────────────────
@ -44,12 +45,14 @@ type FileEntry = {
measureName: string;
};
type Phase = "loading" | "upload" | "classify";
type Phase = "loading" | "measure-select" | "upload" | "classify";
type Props = {
deal: ClassifiedDeal;
portfolioId: string;
onClose: () => void;
docStatusMap?: DocStatusMap;
approvedMeasures?: string[]; // if non-empty, used instead of proposedMeasures
};
// ── Constants ─────────────────────────────────────────────────────────────
@ -200,11 +203,13 @@ async function recordUpload(payload: {
async function saveClassifications(
updates: { id: string; fileType: string; measureName?: string }[],
hubspotDealId?: string,
proposedMeasures?: string[],
): Promise<void> {
const res = await fetch("/api/upload/contractor-install", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ updates }),
body: JSON.stringify({ updates, hubspotDealId, proposedMeasures }),
});
if (!res.ok) throw new Error("Failed to save classifications");
}
@ -257,7 +262,121 @@ function PasGuidancePanel() {
// ── DocType select ────────────────────────────────────────────────────────
function DocTypeSelect({ value, onChange, showHint = false }: { value: string; onChange: (v: string) => void; showHint?: boolean }) {
// ── DocType button grid — shown when a measure is selected ───────────────
function DocTypeButtonGrid({
value,
onChange,
requiredDocs,
uploadedDocs,
}: {
value: string;
onChange: (v: string) => void;
requiredDocs: string[];
uploadedDocs: string[];
}) {
const [showOther, setShowOther] = useState(false);
const uploadedSet = new Set(uploadedDocs);
const requiredSet = new Set(requiredDocs);
const isOtherSelected = value !== "" && !requiredSet.has(value);
return (
<div className="space-y-2">
{/* Required doc type buttons */}
<div className="flex flex-wrap gap-1.5">
{requiredDocs.map((docType) => {
const option = FILE_TYPE_OPTIONS.find((o) => o.value === docType);
const label = option?.label ?? docType;
const alreadyUploaded = uploadedSet.has(docType);
const isSelected = value === docType;
return (
<button
key={docType}
type="button"
onClick={() => { onChange(docType); setShowOther(false); }}
title={alreadyUploaded ? `${label} — already uploaded` : label}
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all duration-100 ${
isSelected
? "bg-brandblue text-white border-brandblue shadow-sm"
: alreadyUploaded
? "bg-emerald-50 text-emerald-700 border-emerald-200 hover:border-emerald-400"
: "bg-white text-gray-700 border-gray-200 hover:border-brandblue/50 hover:bg-brandlightblue/10"
}`}
>
{alreadyUploaded && !isSelected && (
<svg className="h-3 w-3 text-emerald-500 shrink-0" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{label}
</button>
);
})}
{/* Other button */}
<button
type="button"
onClick={() => setShowOther((v) => !v)}
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-all duration-100 ${
isOtherSelected || showOther
? "bg-gray-100 text-gray-700 border-gray-300"
: "bg-white text-gray-400 border-gray-200 hover:border-gray-400 hover:text-gray-600"
}`}
>
Other {showOther ? "▲" : "▼"}
</button>
</div>
{/* Other: dropdown for non-required types */}
{(showOther || isOtherSelected) && (
<Select
value={isOtherSelected ? value : "__unset__"}
onValueChange={(v) => { onChange(v === "__unset__" ? "" : v); }}
>
<SelectTrigger className="h-8 text-xs w-full">
<SelectValue placeholder="Select other type…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unset__" className="text-xs text-gray-400">Select other type</SelectItem>
{FILE_TYPE_GROUPS.map((group) => {
const items = FILE_TYPE_OPTIONS.filter(
(o) => o.group === group && !requiredSet.has(o.value),
);
if (!items.length) return null;
return (
<SelectGroup key={group}>
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 px-2 py-1">
{group}
</SelectLabel>
{items.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
))}
</SelectGroup>
);
})}
</SelectContent>
</Select>
)}
{/* Show hint for selected type */}
{value && (() => {
const hint = FILE_TYPE_OPTIONS.find((o) => o.value === value)?.hint;
return hint ? <p className="text-[10px] text-blue-600 leading-snug">{hint}</p> : null;
})()}
</div>
);
}
// ── DocType select — fallback when no measure selected ────────────────────
function DocTypeSelect({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const selected = FILE_TYPE_OPTIONS.find((o) => o.value === value);
return (
@ -277,16 +396,14 @@ function DocTypeSelect({ value, onChange, showHint = false }: { value: string; o
{group}
</SelectLabel>
{items.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-xs">
{o.label}
</SelectItem>
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
))}
</SelectGroup>
);
})}
</SelectContent>
</Select>
{showHint && selected?.hint && (
{selected?.hint && (
<p className="text-[10px] text-blue-600 leading-snug px-0.5">{selected.hint}</p>
)}
</div>
@ -305,8 +422,11 @@ function StatusIcon({ status, isExisting, errorMsg }: { status: FileStatus; isEx
// ── Main component ─────────────────────────────────────────────────────────
export default function ContractorUploadModal({ deal, portfolioId, onClose }: Props) {
const measures = parseMeasures(deal.proposedMeasures);
export default function ContractorUploadModal({ deal, portfolioId, onClose, docStatusMap, approvedMeasures }: Props) {
// Use approved measures when available; fall back to all proposed measures
const measures = (approvedMeasures && approvedMeasures.length > 0)
? approvedMeasures
: parseMeasures(deal.proposedMeasures);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const [queue, setQueue] = useState<FileEntry[]>([]);
@ -314,6 +434,8 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
const [isUploading, setIsUploading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// The measure selected in the measure-select phase (empty = "not measure-specific")
const [selectedMeasure, setSelectedMeasure] = useState<string>("");
// ── Fetch existing unclassified files on mount ───────────────────────
@ -322,7 +444,7 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
const uprnParam = deal.uprn;
const propIdParam = deal.landlordPropertyId;
if (!uprnParam && !propIdParam) {
setPhase("upload");
setPhase(measures.length > 0 ? "measure-select" : "upload");
return;
}
@ -350,12 +472,14 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
}));
setQueue(entries);
setPhase("classify");
} else if (measures.length > 0) {
setPhase("measure-select");
} else {
setPhase("upload");
}
} catch {
// If fetch fails, just proceed to upload phase
setPhase("upload");
// If fetch fails, just proceed to measure-select (or upload if no measures)
setPhase(measures.length > 0 ? "measure-select" : "upload");
}
}
@ -373,7 +497,7 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
displaySize: formatSize(f.size),
status: "queued",
docType: "",
measureName: measures[0] ?? "",
measureName: selectedMeasure,
}));
setQueue((prev) => [...prev, ...newEntries]);
}
@ -473,6 +597,8 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
fileType: f.docType,
measureName: (f.measureName && f.measureName !== "__none__") ? f.measureName : undefined,
})),
deal.dealId,
measures.length > 0 ? measures : undefined,
);
onClose();
} catch {
@ -496,14 +622,24 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
<DialogHeader>
<DialogTitle>
{phase === "loading" ? "Loading…" :
phase === "measure-select" ? "Select Measure" :
phase === "upload" ? "Upload Documents" :
"Classify Documents"}
</DialogTitle>
<DialogDescription>
{phase === "loading" && "Checking for pending files…"}
{phase === "measure-select" && (
<>
Which measure are you uploading documents for?{" "}
<strong>{propertyLabel}</strong>
</>
)}
{phase === "upload" && (
<>
Upload install documents for <strong>{propertyLabel}</strong>.
{selectedMeasure
? <>Uploading documents for <strong>{selectedMeasure}</strong> <strong>{propertyLabel}</strong>.</>
: <>Upload install documents for <strong>{propertyLabel}</strong>.</>
}
{existingCount > 0 && ` ${existingCount} file${existingCount !== 1 ? "s" : ""} are pending classification.`}
</>
)}
@ -525,6 +661,55 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
</div>
)}
{/* ── Measure Select ── */}
{phase === "measure-select" && (() => {
const docStatus = docStatusMap?.[deal.dealId];
const measureProgressMap = new Map(
(docStatus?.measureProgress ?? []).map((m) => [m.measureName, m]),
);
return (
<div className="space-y-3">
<p className="text-xs text-gray-500">
Select the measure you are uploading documents for. This helps track completion against required documents.
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{measures.map((measure) => {
const progress = measureProgressMap.get(measure);
const isComplete = progress?.isComplete ?? false;
const uploaded = progress?.uploadedCount ?? 0;
const required = progress?.requiredCount ?? getRequiredDocs(measure).length;
return (
<button
key={measure}
type="button"
onClick={() => { setSelectedMeasure(measure); setPhase("upload"); }}
className={`flex flex-col items-start gap-1 rounded-lg border px-3 py-2.5 text-left transition-colors hover:border-brandblue/40 hover:bg-brandlightblue/10 ${
isComplete
? "border-emerald-200 bg-emerald-50/60"
: uploaded > 0
? "border-amber-200 bg-amber-50/50"
: "border-gray-200 bg-white"
}`}
>
<span className="text-xs font-semibold text-gray-800 leading-tight">{measure}</span>
<span className={`text-[10px] font-medium ${isComplete ? "text-emerald-600" : uploaded > 0 ? "text-amber-600" : "text-gray-400"}`}>
{isComplete ? "✓ Complete" : `${uploaded} / ${required} docs`}
</span>
</button>
);
})}
</div>
<button
type="button"
onClick={() => { setSelectedMeasure(""); setPhase("upload"); }}
className="text-xs text-gray-400 hover:text-gray-600 underline underline-offset-2 mt-1"
>
Not measure-specific / other
</button>
</div>
);
})()}
{/* ── Phase 1: Upload ── */}
{phase === "upload" && (
<>
@ -592,46 +777,107 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
)}
{/* ── Phase 2: Classify ── */}
{phase === "classify" && (
<div className="space-y-3">
{phase === "classify" && (() => {
const docStatus = docStatusMap?.[deal.dealId];
const measureProgressMap = new Map(
(docStatus?.measureProgress ?? []).map((m) => [m.measureName, m]),
);
return (
<div className="space-y-4">
{/* PAS guidance */}
<PasGuidancePanel />
{/* Column headers */}
<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_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">
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
{entry.displaySize && <p className="text-[10px] text-gray-400">{entry.displaySize}</p>}
{entry.existingS3Key && <p className="text-[10px] text-amber-500">Previously uploaded</p>}
</div>
{/* Measure context banner */}
{selectedMeasure && (
<div className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-brandlightblue/20 border border-brandblue/20">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-brandblue">{selectedMeasure}</span>
{(() => {
const mp = measureProgressMap.get(selectedMeasure);
if (!mp) return null;
return (
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full border ${
mp.isComplete ? "bg-emerald-50 text-emerald-700 border-emerald-200" : "bg-amber-50 text-amber-700 border-amber-200"
}`}>
{mp.uploadedCount}/{mp.requiredCount} docs uploaded
</span>
);
})()}
</div>
<DocTypeSelect value={entry.docType} onChange={(v) => updateEntryField(entry.id, "docType", v)} showHint />
{measures.length > 0 ? (
<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="__none__" className="text-xs text-gray-400"> None </SelectItem>
{measures.map((m) => (
<SelectItem key={m} value={m} className="text-xs">{m}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="text-xs text-gray-300"></span>
)}
<button
type="button"
onClick={() => setPhase("measure-select")}
className="text-[10px] text-brandblue/60 hover:text-brandblue underline underline-offset-2"
>
Change
</button>
</div>
))}
)}
{/* File list with classification */}
<div className="space-y-3">
{classifiableEntries.map((entry) => {
const entryMeasure = entry.measureName && entry.measureName !== "__none__" ? entry.measureName : null;
const requiredDocs = entryMeasure ? getRequiredDocs(entryMeasure) : null;
const uploadedDocs = entryMeasure ? (measureProgressMap.get(entryMeasure)?.uploaded ?? []) : [];
return (
<div key={entry.id} className="rounded-lg border border-gray-100 bg-gray-50/50 p-3 space-y-2.5">
{/* File info row */}
<div className="flex items-center gap-2 min-w-0">
<StatusIcon status={entry.status} isExisting={!!entry.existingS3Key} />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-gray-700 truncate">{entry.displayName}</p>
<p className="text-[10px] text-gray-400">
{entry.existingS3Key ? "Previously uploaded · " : ""}
{entry.displaySize ?? ""}
{entryMeasure && !selectedMeasure && (
<span className="ml-1 text-brandblue/70">{entryMeasure}</span>
)}
</p>
</div>
{/* Measure selector — only shown if no pre-selected measure */}
{!selectedMeasure && measures.length > 0 && (
<Select
value={entry.measureName || "__none__"}
onValueChange={(v) => updateEntryField(entry.id, "measureName", v === "__none__" ? "" : v)}
>
<SelectTrigger className="h-7 text-[10px] w-36 shrink-0">
<SelectValue placeholder="Measure…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs text-gray-400"> None </SelectItem>
{measures.map((m) => (
<SelectItem key={m} value={m} className="text-xs">{m}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Doc type selector */}
<div>
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide mb-1.5">
Document type <span className="text-red-400">*</span>
</p>
{requiredDocs ? (
<DocTypeButtonGrid
value={entry.docType}
onChange={(v) => updateEntryField(entry.id, "docType", v)}
requiredDocs={requiredDocs}
uploadedDocs={uploadedDocs}
/>
) : (
<DocTypeSelect
value={entry.docType}
onChange={(v) => updateEntryField(entry.id, "docType", v)}
/>
)}
</div>
</div>
);
})}
</div>
{/* Failed uploads (info only) */}
{queue.filter((f) => f.status === "error").length > 0 && (
@ -651,7 +897,8 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
</p>
)}
</div>
)}
);
})()}
</div>
<DialogFooter className="pt-2 border-t border-gray-100 shrink-0">
@ -659,6 +906,10 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose }: Pr
<Button variant="secondary" onClick={onClose}>Cancel</Button>
)}
{phase === "measure-select" && (
<Button variant="secondary" onClick={onClose}>Cancel</Button>
)}
{phase === "upload" && (
<>
<Button variant="secondary" onClick={onClose} disabled={isUploading}>Cancel</Button>

View file

@ -121,6 +121,7 @@ export default function DampMouldRiskPanel({
"dealname",
"landlordPropertyId",
"dampMouldFlag",
"dampMouldAndRepairComments",
"coordinator",
];
@ -128,6 +129,7 @@ export default function DampMouldRiskPanel({
dealname: "Address",
landlordPropertyId: "Property Ref",
dampMouldFlag: "Coordinator Flag",
dampMouldAndRepairComments: "Comments",
coordinator: "Coordinator",
};

View file

@ -29,17 +29,18 @@ import {
import { Search, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { createDocumentTableColumns } from "./DocumentTableColumns";
import ContractorUploadModal from "./ContractorUploadModal";
import type { ClassifiedDeal, DocStatusMap, PortfolioCapabilityType } from "./types";
import type { ClassifiedDeal, DocStatusMap, PortfolioCapabilityType, ApprovalsByDeal } from "./types";
type RetroAssessmentFilter = "all" | "none" | "partial" | "complete";
type InstallStatusFilter = "all" | "none" | "hasDocs" | "partial" | "complete";
interface DocumentTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
docStatusMap: DocStatusMap;
portfolioId: string;
userCapability: PortfolioCapabilityType;
approvalsByDeal?: ApprovalsByDeal;
}
function escapeCell(value: unknown): string {
@ -53,7 +54,7 @@ function escapeCell(value: unknown): string {
: str;
}
export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability }: DocumentTableProps) {
export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfolioId, userCapability, approvalsByDeal }: DocumentTableProps) {
const [globalFilter, setGlobalFilter] = useState("");
const [retroAssessmentFilter, setRetroAssessmentFilter] = useState<RetroAssessmentFilter>("all");
const [installStatusFilter, setInstallStatusFilter] = useState<InstallStatusFilter>("all");
@ -66,7 +67,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
const filteredData = useMemo(() => {
return data.filter((d) => {
const status = d.uprn ? docStatusMap[d.uprn] : undefined;
const status = docStatusMap[d.dealId];
if (retroAssessmentFilter !== "all") {
if (retroAssessmentFilter === "none" && !(!status || !status.hasSurveyDocs)) return false;
@ -114,7 +115,7 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
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 status = docStatusMap[row.original.dealId];
const retroStatus = status?.isSurveyComplete
? "Complete"
: status?.hasSurveyDocs
@ -302,6 +303,8 @@ export default function DocumentTable({ data, onOpenDrawer, docStatusMap, portfo
deal={uploadDeal}
portfolioId={portfolioId}
onClose={() => setUploadDeal(null)}
docStatusMap={docStatusMap}
approvedMeasures={approvalsByDeal?.[uploadDeal.dealId] ?? []}
/>
)}

View file

@ -49,19 +49,38 @@ function RetroAssessmentBadge({ status }: { status: DocStatus | undefined }) {
function InstallDocsBadge({ status }: { status: DocStatus | undefined }) {
const installStatus = status?.installStatus ?? "none";
const measureProgress = status?.measureProgress ?? [];
// Build a tooltip showing per-measure doc counts (e.g. "ASHP: 5/9, CWI: 7/7")
const tooltip =
measureProgress.length > 0
? measureProgress
.map((m) => `${m.measureName}: ${m.uploadedCount}/${m.requiredCount}`)
.join(" | ")
: undefined;
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">
<span
title={tooltip}
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") {
const completedCount = measureProgress.filter((m) => m.isComplete).length;
const totalCount = measureProgress.length;
const label = totalCount > 0 ? `${completedCount} / ${totalCount} measures` : "Some Measures";
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">
<span
title={tooltip}
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
{label}
</span>
);
}
@ -82,7 +101,7 @@ function InstallDocsBadge({ status }: { status: DocStatus | undefined }) {
}
export function createDocumentTableColumns(
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
docStatusMap: DocStatusMap = {},
onUpload?: (deal: ClassifiedDeal) => void,
): ColumnDef<ClassifiedDeal>[] {
@ -119,14 +138,14 @@ export function createDocumentTableColumns(
{
id: "retroAssessmentStatus",
accessorFn: (row) => {
const status = row.uprn ? docStatusMap[row.uprn] : undefined;
const status = docStatusMap[row.dealId];
if (status?.isSurveyComplete) return 2;
if (status?.hasSurveyDocs) return 1;
return 0;
},
header: ({ column }) => <SortableHeader label="Retrofit Assessment Docs" column={column as any} />,
cell: ({ row }) => {
const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
const status = docStatusMap[row.original.dealId];
return <RetroAssessmentBadge status={status} />;
},
enableHiding: false,
@ -136,7 +155,7 @@ export function createDocumentTableColumns(
{
id: "installDocs",
accessorFn: (row) => {
const status = row.uprn ? docStatusMap[row.uprn] : undefined;
const status = docStatusMap[row.dealId];
const s = status?.installStatus ?? "none";
if (s === "all") return 3;
if (s === "partial") return 2;
@ -145,7 +164,7 @@ export function createDocumentTableColumns(
},
header: ({ column }) => <SortableHeader label="Install Docs" column={column as any} />,
cell: ({ row }) => {
const status = row.original.uprn ? docStatusMap[row.original.uprn] : undefined;
const status = docStatusMap[row.original.dealId];
return <InstallDocsBadge status={status} />;
},
enableHiding: false,
@ -158,8 +177,7 @@ export function createDocumentTableColumns(
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">Docs</span>
),
cell: ({ row }) => {
const uprn = row.original.uprn ?? "";
const status = uprn ? docStatusMap[uprn] : undefined;
const status = docStatusMap[row.original.dealId];
let icon: React.ReactNode;
let className: string;
@ -182,6 +200,7 @@ export function createDocumentTableColumns(
<button
onClick={() =>
onOpenDrawer(
row.original.dealId,
row.original.uprn,
row.original.landlordPropertyId,
row.original.dealname,

View file

@ -9,7 +9,13 @@ import {
TabsTrigger,
} from "@/app/shadcn_components/ui/tabs";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { BarChart2, Table2, FolderOpen, Wrench, AlertTriangle } from "lucide-react";
import {
BarChart2,
Table2,
FolderOpen,
Wrench,
AlertTriangle,
} from "lucide-react";
import DrillDownTable from "./DrillDownTable";
import PropertyTable from "./PropertyTable";
import DocumentTable from "./DocumentTable";
@ -40,9 +46,9 @@ export default function LiveTracker({
userEmail,
}: LiveTrackerProps) {
// ── Tab state ────────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState<"analytics" | "properties" | "documents" | "measures">(
"analytics",
);
const [activeTab, setActiveTab] = useState<
"analytics" | "properties" | "documents" | "measures"
>("analytics");
// ── Project selector (shared across both tabs) ───────────────────────
const projectCodes = projects.map((p) => p.projectCode);
@ -63,6 +69,7 @@ export default function LiveTracker({
// ── Document drawer (used by PropertyTable) ──────────────────────────
const [drawerState, setDrawerState] = useState<DocumentDrawerState>({
open: false,
dealId: null,
uprn: null,
landlordPropertyId: null,
dealname: null,
@ -81,7 +88,10 @@ export default function LiveTracker({
setOpenTable({
stage,
data: filteredDeals,
columns: (columns || ["dealname", "landlordPropertyId"]) as (keyof ClassifiedDeal)[],
columns: (columns || [
"dealname",
"landlordPropertyId",
]) as (keyof ClassifiedDeal)[],
columnLabels: (columnLabels || {
dealname: "Address Ref.",
landlordPropertyId: "Property Ref.",
@ -90,8 +100,13 @@ export default function LiveTracker({
});
};
const handleOpenDrawer = (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => {
setDrawerState({ open: true, uprn, landlordPropertyId, dealname });
const handleOpenDrawer = (
dealId: string,
uprn: string | null,
landlordPropertyId: string | null,
dealname: string | null,
) => {
setDrawerState({ open: true, dealId, uprn, landlordPropertyId, dealname });
};
if (!totalDeals) {
@ -108,7 +123,11 @@ export default function LiveTracker({
<div className="space-y-4 w-full">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "analytics" | "properties" | "documents" | "measures")}
onValueChange={(v) =>
setActiveTab(
v as "analytics" | "properties" | "documents" | "measures",
)
}
>
{/* Tab bar */}
<TabsList className="h-10 p-1 bg-brandlightblue/10 border border-brandblue/10 rounded-xl mb-6">
@ -127,7 +146,9 @@ export default function LiveTracker({
Properties
<span
className={`ml-1 min-w-[18px] h-[18px] px-1 rounded-full bg-amber-500 text-white text-[10px] font-bold flex items-center justify-center leading-none transition-opacity ${
pendingRemovalCount > 0 ? "opacity-100" : "opacity-0 pointer-events-none"
pendingRemovalCount > 0
? "opacity-100"
: "opacity-0 pointer-events-none"
}`}
aria-hidden={pendingRemovalCount === 0}
>
@ -139,7 +160,7 @@ export default function LiveTracker({
className="flex items-center gap-2 rounded-lg text-sm font-medium px-4 data-[state=active]:bg-white data-[state=active]:text-brandblue data-[state=active]:shadow-sm transition-all"
>
<FolderOpen className="h-3.5 w-3.5" />
Document Management
Documents
</TabsTrigger>
<TabsTrigger
value="measures"
@ -196,11 +217,14 @@ export default function LiveTracker({
</div>
)}
<div className={`flex items-center gap-2.5 px-4 py-3 rounded-xl border border-amber-200 bg-amber-50 text-amber-800 text-sm ${pendingRemovalCount === 0 ? "hidden" : ""}`}>
<div
className={`flex items-center gap-2.5 px-4 py-3 rounded-xl border border-amber-200 bg-amber-50 text-amber-800 text-sm ${pendingRemovalCount === 0 ? "hidden" : ""}`}
>
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
<span>
<span className="font-semibold">{pendingRemovalCount}</span>{" "}
{pendingRemovalCount === 1 ? "property has" : "properties have"} an outstanding removal request
{pendingRemovalCount === 1 ? "property has" : "properties have"}{" "}
an outstanding removal request
</span>
</div>
<PropertyTable
@ -225,7 +249,11 @@ export default function LiveTracker({
>
{projectCodes.map((code) =>
code === "__ALL__" ? (
<option key="__ALL__" value="__ALL__" style={{ fontWeight: 700 }}>
<option
key="__ALL__"
value="__ALL__"
style={{ fontWeight: 700 }}
>
All Projects
</option>
) : (
@ -243,6 +271,7 @@ export default function LiveTracker({
docStatusMap={docStatusMap}
portfolioId={portfolioId}
userCapability={userCapability}
approvalsByDeal={approvalsByDeal}
/>
</div>
</TabsContent>
@ -260,7 +289,11 @@ export default function LiveTracker({
>
{projectCodes.map((code) =>
code === "__ALL__" ? (
<option key="__ALL__" value="__ALL__" style={{ fontWeight: 700 }}>
<option
key="__ALL__"
value="__ALL__"
style={{ fontWeight: 700 }}
>
All Projects
</option>
) : (
@ -373,11 +406,21 @@ export default function LiveTracker({
{/* ── Document drawer ────────────────────────────────────────────── */}
<PropertyDrawer
open={drawerState.open}
dealId={drawerState.dealId}
uprn={drawerState.uprn}
landlordPropertyId={drawerState.landlordPropertyId}
dealname={drawerState.dealname}
docStatus={
drawerState.dealId ? docStatusMap[drawerState.dealId] : undefined
}
onClose={() =>
setDrawerState({ open: false, uprn: null, landlordPropertyId: null, dealname: null })
setDrawerState({
open: false,
dealId: null,
uprn: null,
landlordPropertyId: null,
dealname: null,
})
}
/>

View file

@ -21,7 +21,7 @@ import {
DrawerTitle,
DrawerDescription,
} from "@/app/shadcn_components/ui/drawer";
import type { PropertyDocument } from "./types";
import type { PropertyDocument, DocStatus } from "./types";
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
// Human-readable labels for all DB fileType enum values
@ -86,11 +86,9 @@ function formatDate(iso: string): string {
}
// -----------------------------------------------------------------------
// Individual document row
// Reusable download button — encapsulates the presigned URL mutation
// -----------------------------------------------------------------------
function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) {
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
function DownloadDocButton({ doc }: { doc: PropertyDocument }) {
const { mutate: download, isPending: signing } = useMutation({
mutationFn: async () => {
const res = await fetch("/api/sign-document-url", {
@ -107,6 +105,28 @@ function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?
},
});
return (
<button
onClick={() => download()}
disabled={signing}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brandblue text-white text-xs font-medium hover:bg-brandblue/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{signing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FileDown className="h-3.5 w-3.5" />
)}
{signing ? "Preparing…" : "Download"}
</button>
);
}
// -----------------------------------------------------------------------
// Individual document row — used in retrofit section and install fallback
// -----------------------------------------------------------------------
function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?: boolean }) {
const label = DOC_TYPE_LABELS[doc.docType] ?? doc.docType;
return (
<motion.div
layout
@ -130,19 +150,7 @@ function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?
</div>
</div>
{/* Right: download button */}
<button
onClick={() => download()}
disabled={signing}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brandblue text-white text-xs font-medium hover:bg-brandblue/90 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{signing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FileDown className="h-3.5 w-3.5" />
)}
{signing ? "Preparing…" : "Download"}
</button>
<DownloadDocButton doc={doc} />
</motion.div>
);
}
@ -152,29 +160,34 @@ function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?
// -----------------------------------------------------------------------
interface PropertyDrawerProps {
open: boolean;
dealId?: string | null;
uprn: string | null;
landlordPropertyId: string | null;
dealname: string | null;
docStatus?: DocStatus;
onClose: () => void;
}
export default function PropertyDrawer({
open,
dealId,
uprn,
landlordPropertyId,
dealname,
docStatus,
onClose,
}: PropertyDrawerProps) {
const canQuery = !!(uprn || landlordPropertyId);
const canQuery = !!(dealId || uprn || landlordPropertyId);
const {
data: fetchedDocuments = [],
isFetching,
isError,
} = useQuery({
queryKey: ["property-documents", uprn, landlordPropertyId],
queryKey: ["property-documents", dealId, uprn, landlordPropertyId],
queryFn: async () => {
const params = new URLSearchParams();
if (uprn) params.set("uprn", uprn);
if (dealId) params.set("dealId", dealId);
else if (uprn) params.set("uprn", uprn);
else if (landlordPropertyId)
params.set("landlordPropertyId", landlordPropertyId);
const res = await fetch(
@ -361,13 +374,112 @@ export default function PropertyDrawer({
key="install"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-2"
className="space-y-3"
>
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-400 px-0.5 flex items-center gap-1.5">
<HardHat className="h-3.5 w-3.5" />
Install Documents
</h3>
{installDocs.length > 0 ? (
{docStatus?.measureProgress && docStatus.measureProgress.length > 0 ? (
// ── Per-measure checklist ──
<div className="space-y-4">
{docStatus.measureProgress.map((mp) => {
const measureDocs = installDocs.filter((d) => d.measureName === mp.measureName);
const uploadedTypeSet = new Set(measureDocs.map((d) => d.docType));
const missingTypes = mp.required.filter((t) => !uploadedTypeSet.has(t));
return (
<div key={mp.measureName} className="rounded-xl border border-gray-100 bg-gray-50/40 overflow-hidden">
{/* Measure header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-gray-100 bg-white">
<span className="text-xs font-semibold text-gray-800">{mp.measureName}</span>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border ${
mp.isComplete
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: mp.uploadedCount > 0
? "bg-amber-50 text-amber-700 border-amber-200"
: "bg-gray-100 text-gray-500 border-gray-200"
}`}>
{mp.uploadedCount} / {mp.requiredCount} docs
</span>
</div>
<div className="px-3 py-2.5 space-y-1.5">
{/* Uploaded required docs */}
{mp.uploaded.map((docType) => {
const doc = measureDocs.find((d) => d.docType === docType);
if (!doc) return null;
return (
<div key={docType} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-emerald-100 bg-emerald-50/50">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0 w-5 h-5 rounded-full bg-emerald-100 border border-emerald-200 flex items-center justify-center">
<svg className="h-3 w-3 text-emerald-600" viewBox="0 0 12 12" fill="none">
<path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[docType] ?? docType}</p>
<p className="text-[10px] text-gray-400">{formatDate(doc.s3UploadTimestamp)}</p>
</div>
</div>
<DownloadDocButton doc={doc} />
</div>
);
})}
{/* Missing required docs */}
{missingTypes.map((docType) => (
<div key={docType} className="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-dashed border-amber-200 bg-amber-50/30">
<div className="shrink-0 w-5 h-5 rounded-full border-2 border-dashed border-amber-300 flex items-center justify-center">
<FileX className="h-2.5 w-2.5 text-amber-400" />
</div>
<p className="text-xs text-amber-700 font-medium">{DOC_TYPE_LABELS[docType] ?? docType}</p>
</div>
))}
{/* Extra docs uploaded for this measure (not in required list) */}
{measureDocs
.filter((d) => !mp.required.includes(d.docType))
.map((doc) => (
<div key={doc.id} className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-gray-100 bg-white">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0 w-5 h-5 rounded-full bg-sky-50 border border-sky-200 flex items-center justify-center">
<FileText className="h-3 w-3 text-sky-500" />
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-gray-800 truncate">{DOC_TYPE_LABELS[doc.docType] ?? doc.docType}</p>
<p className="text-[10px] text-gray-400">{formatDate(doc.s3UploadTimestamp)}</p>
</div>
</div>
<DownloadDocButton doc={doc} />
</div>
))
}
</div>
</div>
);
})}
{/* Unassigned / no-measure install docs */}
{(() => {
const knownMeasures = new Set(docStatus.measureProgress.map((m) => m.measureName));
const unassigned = installDocs.filter(
(d) => !d.measureName || !knownMeasures.has(d.measureName),
);
if (unassigned.length === 0) return null;
return (
<div className="space-y-1.5">
<h4 className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 px-0.5">Other</h4>
{unassigned.map((doc) => (
<DocumentRow key={doc.id} doc={doc} showMeasure />
))}
</div>
);
})()}
</div>
) : installDocs.length > 0 ? (
// ── Fallback: flat list (no measure progress data) ──
<div className="space-y-1.5">
{installDocs.map((doc) => (
<DocumentRow key={doc.id} doc={doc} showMeasure />

View file

@ -52,6 +52,10 @@ const COLUMN_LABELS: Record<string, string> = {
approvedPackage: "Approved Package",
actualMeasuresInstalled: "Installed Measures",
preSapScore: "Pre-SAP",
eiScore: "EI Score",
eiScorePotential: "EI Score (Potential)",
epcSapScore: "EPC SAP Score",
epcSapScorePotential: "EPC SAP (Potential)",
lodgementStatus: "Lodgement Status",
designDate: "Design Date",
fullLodgementDate: "Lodgement Date",
@ -62,7 +66,7 @@ type RemovalFilter = "all" | "pending_removal" | "removed" | "pending_re_additio
interface PropertyTableProps {
data: ClassifiedDeal[];
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void;
onOpenDetail?: (deal: ClassifiedDeal) => void;
showDocuments?: boolean;
docStatusMap?: DocStatusMap;
@ -82,6 +86,10 @@ const CSV_FIELDS: { key: keyof ClassifiedDeal; label: string }[] = [
{ key: "approvedPackage", label: "Approved Package" },
{ key: "actualMeasuresInstalled", label: "Installed Measures" },
{ key: "preSapScore", label: "Pre-SAP" },
{ key: "eiScore", label: "EI Score" },
{ key: "eiScorePotential", label: "EI Score (Potential)" },
{ key: "epcSapScore", label: "EPC SAP Score" },
{ key: "epcSapScorePotential", label: "EPC SAP (Potential)" },
{ key: "lodgementStatus", label: "Lodgement Status" },
{ key: "designDate", label: "Design Date" },
{ key: "fullLodgementDate", label: "Lodgement Date" },
@ -115,6 +123,10 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
approvedPackage: false,
actualMeasuresInstalled: false,
preSapScore: false,
eiScore: false,
eiScorePotential: false,
epcSapScore: false,
epcSapScorePotential: false,
lodgementStatus: false,
designDate: false,
fullLodgementDate: false,
@ -128,7 +140,7 @@ export default function PropertyTable({ data, onOpenDrawer, onOpenDetail, showDo
}
if (docFilter !== "all") {
result = result.filter((d) => {
const status = d.uprn ? docStatusMap[d.uprn] : undefined;
const status = docStatusMap[d.dealId];
if (docFilter === "none") return !status || !status.hasSurveyDocs;
if (docFilter === "has_docs") return !!status?.hasSurveyDocs;
if (docFilter === "incomplete") return !!status?.hasSurveyDocs && !status.isSurveyComplete;

View file

@ -42,10 +42,10 @@ function SortableHeader({
// -----------------------------------------------------------------------
// Column factory — takes onOpenDrawer so the Documents button can trigger it
// showDocuments controls whether the Docs action column is included
// docStatusMap provides per-UPRN document status for status indicators
// docStatusMap provides per-deal document status for status indicators
// -----------------------------------------------------------------------
export function createPropertyTableColumns(
onOpenDrawer: (uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
onOpenDrawer: (dealId: string, uprn: string | null, landlordPropertyId: string | null, dealname: string | null) => void,
showDocuments: boolean = false,
docStatusMap: DocStatusMap = {},
onOpenDetail?: (deal: ClassifiedDeal) => void,
@ -240,6 +240,54 @@ export function createPropertyTableColumns(
},
// ── EI score ─────────────────────────────────────────────────────────
{
accessorKey: "eiScore",
id: "eiScore",
header: ({ column }) => <SortableHeader label="EI Score" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-600">
{row.original.eiScore ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── EI score (potential) ──────────────────────────────────────────────
{
accessorKey: "eiScorePotential",
id: "eiScorePotential",
header: ({ column }) => <SortableHeader label="EI Score (Potential)" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-600">
{row.original.eiScorePotential ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── EPC SAP score ─────────────────────────────────────────────────────
{
accessorKey: "epcSapScore",
id: "epcSapScore",
header: ({ column }) => <SortableHeader label="EPC SAP Score" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-600">
{row.original.epcSapScore ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── EPC SAP score (potential) ─────────────────────────────────────────
{
accessorKey: "epcSapScorePotential",
id: "epcSapScorePotential",
header: ({ column }) => <SortableHeader label="EPC SAP (Potential)" column={column as any} />,
cell: ({ row }) => (
<span className="text-xs font-mono text-gray-600">
{row.original.epcSapScorePotential ?? <span className="text-gray-300"></span>}
</span>
),
},
// ── Lodgement status ─────────────────────────────────────────────────
{
accessorKey: "lodgementStatus",
@ -291,8 +339,7 @@ export function createPropertyTableColumns(
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">Docs</span>
),
cell: ({ row }) => {
const uprn = row.original.uprn ?? "";
const status = uprn ? docStatusMap[uprn] : undefined;
const status = docStatusMap[row.original.dealId];
const isComplete = status?.isSurveyComplete;
const hasDocs = status?.hasSurveyDocs;
@ -315,7 +362,7 @@ export function createPropertyTableColumns(
return (
<button
onClick={() => onOpenDrawer(row.original.uprn, row.original.landlordPropertyId, row.original.dealname)}
onClick={() => onOpenDrawer(row.original.dealId, row.original.uprn, row.original.landlordPropertyId, row.original.dealname)}
className={className}
>
{icon}

View file

@ -13,8 +13,9 @@ import { portfolioCapabilities, portfolioUsers } from "@/app/db/schema/portfolio
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
import { propertyRemovalRequests } from "@/app/db/schema/removal_requests";
import { user as userTable } from "@/app/db/schema/users";
import type { HubspotDeal, DocStatusMap, DocStatus, PortfolioCapabilityType, ApprovalsByDeal, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
import type { HubspotDeal, DocStatusMap, DocStatus, MeasureDocProgress, PortfolioCapabilityType, ApprovalsByDeal, RemovalStatusByDeal, EffectiveRemovalState } from "./types";
import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, SURVEY_ALL_DOC_TYPES } from "./types";
import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements";
import type { InferSelectModel } from "drizzle-orm";
import { Card, CardContent } from "@/app/shadcn_components/ui/card";
import { Building2 } from "lucide-react";
@ -41,6 +42,7 @@ function mapDbRowToHubspotDeal(row: DbDeal): HubspotDeal {
pashubLink: row.pashubLink,
sharepointLink: row.sharepointLink,
dampMouldFlag: row.dampmouldGrowth,
dampMouldAndRepairComments: row.damnpMouldAndRepairComments,
preSapScore: row.preSap,
coordinator: row.coordinator,
ioeV1Date: row.mtpCompletionDate,
@ -59,6 +61,10 @@ function mapDbRowToHubspotDeal(row: DbDeal): HubspotDeal {
confirmedSurveyDate: row.confirmedSurveyDate,
surveyedDate: row.surveyedDate,
designType: row.dealType,
eiScore: row.eiScore,
eiScorePotential: row.eiScorePotential,
epcSapScore: row.epcSapScore,
epcSapScorePotential: row.epcSapScorePotential,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@ -224,74 +230,122 @@ export default async function LiveReportingPage(props: {
if (state !== "none") removalStatusByDeal[row.hubspotDealId] = state;
}
// Fetch survey document status for all properties
const uprnList = deals
// Fetch document status for all deals — two-phase strategy:
// Phase 1: query by dealId (reliable even when UPRN is missing from hubspot_deal_data)
// Phase 2: UPRN fallback only for deals that returned no results in phase 1
const docsByDealId = new Map<string, Array<{ fileType: string; measureName: string | null }>>();
if (dealIds.length > 0) {
const phase1Rows = await db
.select({
hubsotDealId: uploadedFiles.hubsotDealId,
fileType: uploadedFiles.fileType,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(inArray(uploadedFiles.hubsotDealId, dealIds));
for (const row of phase1Rows) {
if (!row.hubsotDealId || row.fileType === null) continue;
if (!docsByDealId.has(row.hubsotDealId)) docsByDealId.set(row.hubsotDealId, []);
docsByDealId.get(row.hubsotDealId)!.push({ fileType: row.fileType, measureName: row.measureName });
}
}
// Phase 2: for deals with no docs from phase 1 that have a UPRN, try UPRN lookup
const dealsWithoutDocs = deals.filter((d) => !docsByDealId.has(d.dealId));
const fallbackUprns = dealsWithoutDocs
.map((d) => d.uprn)
.filter((u): u is string => !!u)
.map((u) => {
try { return BigInt(u); } catch { return null; }
})
.map((u) => { try { return BigInt(u); } catch { return null; } })
.filter((u): u is bigint => u !== null);
let docStatusMap: DocStatusMap = {};
if (uprnList.length > 0) {
const docRows = await db
if (fallbackUprns.length > 0) {
const phase2Rows = await db
.select({
uprn: uploadedFiles.uprn,
fileType: uploadedFiles.fileType,
measureName: uploadedFiles.measureName,
})
.from(uploadedFiles)
.where(inArray(uploadedFiles.uprn, uprnList));
.where(inArray(uploadedFiles.uprn, fallbackUprns));
// Group docs by UPRN
const docsByUprn = new Map<string, Array<{ fileType: string; measureName: string | null }>>();
for (const row of docRows) {
// Map phase 2 UPRN results back to dealId
const uprnToDealId = new Map<string, string>(
dealsWithoutDocs
.filter((d) => d.uprn)
.map((d) => {
try { return [String(BigInt(d.uprn!)), d.dealId] as [string, string]; } catch { return null; }
})
.filter((e): e is [string, string] => e !== null),
);
for (const row of phase2Rows) {
if (row.uprn === null || row.fileType === null) continue;
const key = String(row.uprn);
if (!docsByUprn.has(key)) docsByUprn.set(key, []);
docsByUprn.get(key)!.push({ fileType: row.fileType, measureName: row.measureName });
const dealId = uprnToDealId.get(String(row.uprn));
if (!dealId) continue;
if (!docsByDealId.has(dealId)) docsByDealId.set(dealId, []);
docsByDealId.get(dealId)!.push({ fileType: row.fileType, measureName: row.measureName });
}
}
// 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);
}
}
// Build measures lookup by dealId (approved measures, falling back to proposed)
const measuresByDealId = new Map<string, string[]>();
for (const deal of deals) {
const approved = approvalsByDeal[deal.dealId] ?? [];
const measures = approved.length > 0
? approved
: (deal.proposedMeasures ?? "").split(",").map((m: string) => m.trim()).filter(Boolean);
measuresByDealId.set(deal.dealId, 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));
// Build docStatusMap keyed by dealId
const docStatusMap: DocStatusMap = {};
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";
}
}
for (const [dealId, docs] of docsByDealId) {
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 status: DocStatus = {
presentSurveyTypes: Array.from(surveyTypeSet),
hasSurveyDocs: surveyDocs.length > 0,
isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) => surveyTypeSet.has(t)),
hasInstallDocs: installDocs.length > 0,
installStatus,
const measures = measuresByDealId.get(dealId) ?? [];
// Compute per-measure document progress against the requirements matrix
const measureProgress: MeasureDocProgress[] = measures.map((measureName) => {
const required = getRequiredDocs(measureName);
const docsForMeasure = installDocs.filter((d) => d.measureName === measureName);
const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType));
const uploaded = required.filter((r) => uploadedTypeSet.has(r));
return {
measureName,
required,
uploaded,
isComplete: uploaded.length === required.length,
uploadedCount: uploaded.length,
requiredCount: required.length,
};
docStatusMap[uprn] = status;
});
let installStatus: DocStatus["installStatus"] = "none";
if (installDocs.length > 0) {
if (measures.length === 0) {
installStatus = "hasDocs";
} else {
installStatus = measureProgress.every((m) => m.isComplete)
? "all"
: measureProgress.some((m) => m.uploadedCount > 0)
? "partial"
: "none";
}
}
docStatusMap[dealId] = {
presentSurveyTypes: Array.from(surveyTypeSet),
hasSurveyDocs: surveyDocs.length > 0,
isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) => surveyTypeSet.has(t)),
hasInstallDocs: installDocs.length > 0,
installStatus,
measureProgress,
};
}
return (

View file

@ -28,6 +28,7 @@ export type HubspotDeal = {
pashubLink: string | null;
sharepointLink: string | null;
dampMouldFlag: string | null; // coordinator-stage damp/mould flag
dampMouldAndRepairComments: string | null; // coordinator damp/mould comments
preSapScore: string | null; // kept as text (HubSpot returns strings)
coordinator: string | null;
ioeV1Date: Date | null;
@ -46,6 +47,10 @@ export type HubspotDeal = {
confirmedSurveyDate: Date | null;
surveyedDate: Date | null;
designType: string | null;
eiScore: string | null;
eiScorePotential: string | null;
epcSapScore: string | null;
epcSapScorePotential: string | null;
createdAt: Date;
updatedAt: Date;
@ -250,6 +255,16 @@ export const SURVEY_ALL_DOC_TYPES = new Set<string>([
"ecmk_survey_xml",
]);
// Per-measure document upload progress
export type MeasureDocProgress = {
measureName: string;
required: string[]; // required fileType values for this measure
uploaded: string[]; // required fileType values that have been uploaded
isComplete: boolean;
uploadedCount: number;
requiredCount: number;
};
export type DocStatus = {
// Retrofit assessment docs
presentSurveyTypes: string[];
@ -258,16 +273,18 @@ export type DocStatus = {
// 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
// "all" = all required docs uploaded for every proposed measure
// "partial" = some (but not all) proposed measures have complete docs
// "hasDocs" = has install docs but no measures defined on the deal
// "none" = no install docs at all
measureProgress: MeasureDocProgress[]; // one entry per proposed measure
};
export type DocStatusMap = Record<string, DocStatus>; // keyed by UPRN string
export type DocStatusMap = Record<string, DocStatus>; // keyed by dealId string
export type DocumentDrawerState = {
open: boolean;
dealId: string | null;
uprn: string | null;
landlordPropertyId: string | null;
dealname: string | null;