implemeting document search on deal id instead of uprn

This commit is contained in:
Khalim Conn-Kowlessar 2026-04-20 18:58:24 +00:00
parent a0b12673f3
commit c7a5780877
10 changed files with 138 additions and 103 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

@ -663,8 +663,7 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
{/* ── Measure Select ── */}
{phase === "measure-select" && (() => {
const uprn = deal.uprn ?? null;
const docStatus = uprn ? docStatusMap?.[uprn] : undefined;
const docStatus = docStatusMap?.[deal.dealId];
const measureProgressMap = new Map(
(docStatus?.measureProgress ?? []).map((m) => [m.measureName, m]),
);
@ -779,8 +778,7 @@ export default function ContractorUploadModal({ deal, portfolioId, onClose, docS
{/* ── Phase 2: Classify ── */}
{phase === "classify" && (() => {
const uprn = deal.uprn ?? null;
const docStatus = uprn ? docStatusMap?.[uprn] : undefined;
const docStatus = docStatusMap?.[deal.dealId];
const measureProgressMap = new Map(
(docStatus?.measureProgress ?? []).map((m) => [m.measureName, m]),
);

View file

@ -36,7 +36,7 @@ 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;
@ -67,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;
@ -115,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

View file

@ -101,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>[] {
@ -138,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,
@ -155,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;
@ -164,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,
@ -177,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;
@ -201,6 +200,7 @@ export function createDocumentTableColumns(
<button
onClick={() =>
onOpenDrawer(
row.original.dealId,
row.original.uprn,
row.original.landlordPropertyId,
row.original.dealname,

View file

@ -69,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,
@ -100,11 +101,12 @@ export default function LiveTracker({
};
const handleOpenDrawer = (
dealId: string,
uprn: string | null,
landlordPropertyId: string | null,
dealname: string | null,
) => {
setDrawerState({ open: true, uprn, landlordPropertyId, dealname });
setDrawerState({ open: true, dealId, uprn, landlordPropertyId, dealname });
};
if (!totalDeals) {
@ -404,15 +406,17 @@ export default function LiveTracker({
{/* ── Document drawer ────────────────────────────────────────────── */}
<PropertyDrawer
open={drawerState.open}
dealId={drawerState.dealId}
uprn={drawerState.uprn}
landlordPropertyId={drawerState.landlordPropertyId}
dealname={drawerState.dealname}
docStatus={
drawerState.uprn ? docStatusMap[drawerState.uprn] : undefined
drawerState.dealId ? docStatusMap[drawerState.dealId] : undefined
}
onClose={() =>
setDrawerState({
open: false,
dealId: null,
uprn: null,
landlordPropertyId: null,
dealname: null,

View file

@ -160,6 +160,7 @@ function DocumentRow({ doc, showMeasure }: { doc: PropertyDocument; showMeasure?
// -----------------------------------------------------------------------
interface PropertyDrawerProps {
open: boolean;
dealId?: string | null;
uprn: string | null;
landlordPropertyId: string | null;
dealname: string | null;
@ -169,22 +170,24 @@ interface PropertyDrawerProps {
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(

View file

@ -62,7 +62,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;
@ -128,7 +128,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,
@ -291,8 +291,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 +314,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

@ -225,95 +225,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 → approved measures, falling back to proposed)
const measuresByUprn = new Map<string, string[]>();
for (const deal of deals) {
if (deal.uprn) {
const key = String(deal.uprn);
const approved = approvalsByDeal[deal.dealId] ?? [];
const measures = approved.length > 0
? approved
: (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) ?? [];
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));
// 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,
};
});
const measures = measuresByDealId.get(dealId) ?? [];
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";
}
}
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,
measureProgress,
// 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

@ -275,10 +275,11 @@ export type DocStatus = {
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;