From c3cc123a6fc2cda4f03b3580583a6eaa15942e7c Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Fri, 24 Apr 2026 15:57:30 +0000 Subject: [PATCH] save working progress --- .claude/settings.local.json | 3 +- .../[uploadId]/combined-results/route.ts | 166 --------------- .../bulk-uploads/[uploadId]/finalize/route.ts | 193 +++++++++++++++++ src/app/db/schema/property.ts | 1 + .../[uploadId]/OnboardingProgress.tsx | 72 ++++++- .../confirm-matches/ConfirmMatchesClient.tsx | 197 ------------------ .../[uploadId]/confirm-matches/page.tsx | 113 ---------- .../bulk-upload/[uploadId]/page.tsx | 28 ++- .../[slug]/components/PropertyTable.tsx | 2 +- .../components/propertyTableColumns.tsx | 19 ++ src/app/portfolio/[slug]/utils.ts | 6 +- 11 files changed, 299 insertions(+), 501 deletions(-) delete mode 100644 src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combined-results/route.ts create mode 100644 src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts delete mode 100644 src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/confirm-matches/ConfirmMatchesClient.tsx delete mode 100644 src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/confirm-matches/page.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6aad418..33ffcd4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,8 @@ "Bash(echo \"EXIT: $?\")", "mcp__backlog__task_list", "Bash(grep -E \"\\\\.\\(prisma|sql|ts\\)$\")", - "Bash(xargs cat *)" + "Bash(xargs cat *)", + "Bash(node -e ' *)" ] }, "enabledMcpjsonServers": [ diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combined-results/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combined-results/route.ts deleted file mode 100644 index d0cf1ca..0000000 --- a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combined-results/route.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { db } from "@/app/db/db"; -import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; -import { eq } from "drizzle-orm"; -import { NextRequest, NextResponse } from "next/server"; -import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import S3 from "aws-sdk/clients/s3"; -import * as XLSX from "xlsx"; - -const ADDRESS_COLS = ["Address 1", "Address 2", "Address 3", "postcode"] as const; -const INTERNAL_REF_COL = "Internal Reference"; -const UPRN_COL = "address2uprn_uprn"; -const MATCHED_ADDRESS_COL = "address2uprn_address"; -const LEXISCORE_COL = "address2uprn_lexiscore"; -const MISSING_SENTINEL = "invalid postcode"; -const HIGH_THRESHOLD = 0.85; -const MED_THRESHOLD = 0.65; - -type ScoreBucket = "high" | "med" | "low" | null; - -function scoreBucket(score: number | null): ScoreBucket { - if (score === null) return null; - if (score >= HIGH_THRESHOLD) return "high"; - if (score >= MED_THRESHOLD) return "med"; - return "low"; -} - -function normalize(v: unknown): string { - if (v === null || v === undefined) return ""; - return String(v).trim(); -} - -function isMissingUprn(uprn: string): boolean { - return uprn === "" || uprn.toLowerCase() === MISSING_SENTINEL; -} - -function parseLexiscore(raw: unknown): number | null { - const val = normalize(raw); - if (!val || val.toLowerCase() === MISSING_SENTINEL) return null; - const n = Number(val); - return Number.isFinite(n) ? n : null; -} - -function parseS3Uri(uri: string): { bucket: string; key: string } | null { - if (!uri.startsWith("s3://")) return null; - const rest = uri.slice(5); - const slash = rest.indexOf("/"); - if (slash < 0) return null; - return { bucket: rest.slice(0, slash), key: rest.slice(slash + 1) }; -} - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } -) { - const session = await getServerSession(AuthOptions); - if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - - const { uploadId } = await params; - - const [upload] = await db - .select({ - combinedOutputS3Uri: bulkAddressUploads.combinedOutputS3Uri, - }) - .from(bulkAddressUploads) - .where(eq(bulkAddressUploads.id, uploadId)) - .limit(1); - - if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 }); - if (!upload.combinedOutputS3Uri) - return NextResponse.json({ error: "Combiner not finished" }, { status: 409 }); - - const parsed = parseS3Uri(upload.combinedOutputS3Uri); - if (!parsed) - return NextResponse.json({ error: "Invalid combined output S3 URI" }, { status: 500 }); - - const { searchParams } = new URL(request.url); - const offset = Math.max(0, parseInt(searchParams.get("offset") ?? "0", 10) || 0); - const limit = Math.max(1, Math.min(5000, parseInt(searchParams.get("limit") ?? "500", 10) || 500)); - - const s3 = new S3({ - region: process.env.RETROFIT_DATA_DEV_REGION, - accessKeyId: process.env.RETROFIT_DATA_DEV_ACCESS_KEY, - secretAccessKey: process.env.RETROFIT_DATA_DEV_SECRET_KEY, - }); - - let rawRows: Record[]; - try { - const obj = await s3 - .getObject({ Bucket: parsed.bucket, Key: parsed.key }) - .promise(); - const buf = Buffer.from(obj.Body as Uint8Array); - const wb = XLSX.read(buf, { type: "buffer" }); - const sheet = wb.Sheets[wb.SheetNames[0]]; - rawRows = XLSX.utils.sheet_to_json>(sheet, { defval: "" }); - } catch (err) { - console.error("Failed to read combined CSV from S3:", err); - return NextResponse.json({ error: "Failed to read combined CSV" }, { status: 502 }); - } - - const uprnValues = rawRows.map((r) => normalize(r[UPRN_COL])); - const uprnCounts = new Map(); - for (const u of uprnValues) { - if (isMissingUprn(u)) continue; - uprnCounts.set(u, (uprnCounts.get(u) ?? 0) + 1); - } - const duplicateUprns = new Set( - Array.from(uprnCounts.entries()) - .filter(([, c]) => c >= 2) - .map(([u]) => u) - ); - - const missingCount = uprnValues.filter(isMissingUprn).length; - const duplicateCount = uprnValues.filter((u) => duplicateUprns.has(u)).length; - const matchedCount = rawRows.length - missingCount; - - const page = rawRows.slice(offset, offset + limit); - const rows = page.map((raw, i) => { - const rowIndex = offset + i; - const addressParts = ADDRESS_COLS.map((c) => normalize(raw[c])).filter(Boolean); - const inputAddress = addressParts.join(", "); - const internalRef = normalize(raw[INTERNAL_REF_COL]) || null; - - const uprnRaw = normalize(raw[UPRN_COL]); - const uprn = isMissingUprn(uprnRaw) ? null : uprnRaw; - - const matchedAddressRaw = normalize(raw[MATCHED_ADDRESS_COL]); - const matchedAddress = - !matchedAddressRaw || matchedAddressRaw.toLowerCase() === MISSING_SENTINEL - ? null - : matchedAddressRaw; - - const lexiscore = parseLexiscore(raw[LEXISCORE_COL]); - - const flags: ("duplicate" | "missing")[] = []; - if (uprn === null) flags.push("missing"); - else if (duplicateUprns.has(uprn)) flags.push("duplicate"); - - return { - row_index: rowIndex, - input_address: inputAddress, - internal_reference: internalRef, - uprn, - matched_address: matchedAddress, - lexiscore, - score_bucket: scoreBucket(lexiscore), - flags, - }; - }); - - return NextResponse.json( - { - task_id: uploadId, - total: rawRows.length, - offset, - limit, - flags_summary: { - duplicates: duplicateCount, - missing: missingCount, - matched: matchedCount, - }, - rows, - }, - { status: 200 } - ); -} diff --git a/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts new file mode 100644 index 0000000..98fb5d1 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/finalize/route.ts @@ -0,0 +1,193 @@ +import { db } from "@/app/db/db"; +import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; +import { property } from "@/app/db/schema/property"; +import { eq, sql } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import { revalidatePath } from "next/cache"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import S3 from "aws-sdk/clients/s3"; +import * as XLSX from "xlsx"; + +const ADDRESS_COLS = ["Address 1", "Address 2", "Address 3"] as const; +const POSTCODE_COL = "postcode"; +const INTERNAL_REF_COL = "Internal Reference"; +const UPRN_COL = "address2uprn_uprn"; +const MATCHED_ADDRESS_COL = "address2uprn_address"; +const LEXISCORE_COL = "address2uprn_lexiscore"; +const MISSING_SENTINEL = "invalid postcode"; +const UK_POSTCODE_RE = /[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}/i; + +function normalize(v: unknown): string { + if (v === null || v === undefined) return ""; + return String(v).trim(); +} + +function isMissing(v: string): boolean { + return v === "" || v.toLowerCase() === MISSING_SENTINEL; +} + +function parseUprn(raw: unknown): bigint | null { + const v = normalize(raw); + if (isMissing(v)) return null; + try { + return BigInt(v); + } catch { + return null; + } +} + +function parseLexiscore(raw: unknown): number | null { + const v = normalize(raw); + if (isMissing(v)) return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +function extractPostcode(matched: string | null, fallback: string): string | null { + if (matched) { + const m = matched.match(UK_POSTCODE_RE); + if (m) return m[0].toUpperCase(); + } + return fallback || null; +} + +function parseS3Uri(uri: string): { bucket: string; key: string } | null { + if (!uri.startsWith("s3://")) return null; + const rest = uri.slice(5); + const slash = rest.indexOf("/"); + if (slash < 0) return null; + return { bucket: rest.slice(0, slash), key: rest.slice(slash + 1) }; +} + +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ portfolioId: string; uploadId: string }> } +) { + const session = await getServerSession(AuthOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { uploadId } = await params; + + const [upload] = await db + .select() + .from(bulkAddressUploads) + .where(eq(bulkAddressUploads.id, uploadId)) + .limit(1); + + if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (upload.status === "complete" || upload.status === "needs_review") { + return NextResponse.json({ alreadyComplete: true, status: upload.status }, { status: 200 }); + } + if (upload.status !== "awaiting_review") { + return NextResponse.json({ error: "Upload not ready to finalize" }, { status: 422 }); + } + if (!upload.combinedOutputS3Uri) { + return NextResponse.json({ error: "Combiner not finished" }, { status: 409 }); + } + + const parsed = parseS3Uri(upload.combinedOutputS3Uri); + if (!parsed) { + return NextResponse.json({ error: "Invalid combined output S3 URI" }, { status: 500 }); + } + + const s3 = new S3({ + region: process.env.RETROFIT_DATA_DEV_REGION, + accessKeyId: process.env.RETROFIT_DATA_DEV_ACCESS_KEY, + secretAccessKey: process.env.RETROFIT_DATA_DEV_SECRET_KEY, + }); + + let rawRows: Record[]; + try { + const obj = await s3 + .getObject({ Bucket: parsed.bucket, Key: parsed.key }) + .promise(); + const buf = Buffer.from(obj.Body as Uint8Array); + const wb = XLSX.read(buf, { type: "buffer" }); + const sheet = wb.Sheets[wb.SheetNames[0]]; + rawRows = XLSX.utils.sheet_to_json>(sheet, { defval: "" }); + } catch (err) { + console.error("Failed to read combined CSV from S3:", err); + return NextResponse.json({ error: "Failed to read combined CSV" }, { status: 502 }); + } + + const portfolioIdBig = BigInt(upload.portfolioId); + + const uprnCounts = new Map(); + for (const r of rawRows) { + const v = normalize(r[UPRN_COL]); + if (isMissing(v)) continue; + uprnCounts.set(v, (uprnCounts.get(v) ?? 0) + 1); + } + + let missingUprnCount = 0; + let duplicateUprnCount = 0; + + const values = rawRows.map((raw) => { + const userInputtedAddress = + ADDRESS_COLS.map((c) => normalize(raw[c])).filter(Boolean).join(", ") || null; + const userInputtedPostcode = normalize(raw[POSTCODE_COL]) || null; + + const uprn = parseUprn(raw[UPRN_COL]); + if (uprn === null) missingUprnCount++; + else if ((uprnCounts.get(uprn.toString()) ?? 0) >= 2) duplicateUprnCount++; + + const matchedAddressRaw = normalize(raw[MATCHED_ADDRESS_COL]); + const matchedAddress = isMissing(matchedAddressRaw) ? null : matchedAddressRaw; + + const address = matchedAddress ?? userInputtedAddress; + const postcode = extractPostcode(matchedAddress, userInputtedPostcode ?? ""); + + const internalRef = normalize(raw[INTERNAL_REF_COL]) || null; + const lexiscore = parseLexiscore(raw[LEXISCORE_COL]); + + return { + portfolioId: portfolioIdBig, + creationStatus: "READY" as const, + uprn, + landlordPropertyId: internalRef, + address, + postcode, + userInputtedAddress, + userInputtedPostcode, + lexiscore, + }; + }); + + let inserted = 0; + try { + if (values.length > 0) { + const result = await db + .insert(property) + .values(values) + .onConflictDoNothing({ + target: [property.portfolioId, property.uprn], + where: sql`${property.uprn} IS NOT NULL`, + }) + .returning({ id: property.id }); + inserted = result.length; + } + + const needsReview = missingUprnCount > 0 || duplicateUprnCount > 0; + const nextStatus = needsReview ? "needs_review" : "complete"; + + await db + .update(bulkAddressUploads) + .set({ status: nextStatus }) + .where(eq(bulkAddressUploads.id, uploadId)); + + revalidatePath("/portfolio/[slug]", "layout"); + + return NextResponse.json( + { inserted, missingUprnCount, duplicateUprnCount, status: nextStatus }, + { status: 200 } + ); + } catch (err) { + console.error("Failed to finalize bulk upload:", err); + const detail = err instanceof Error ? err.message : String(err); + return NextResponse.json( + { error: "Failed to import properties", detail }, + { status: 500 } + ); + } +} diff --git a/src/app/db/schema/property.ts b/src/app/db/schema/property.ts index 132721b..da360a9 100644 --- a/src/app/db/schema/property.ts +++ b/src/app/db/schema/property.ts @@ -385,6 +385,7 @@ export interface PropertyWithRelations extends Record { totalFloorArea: number | null; co2Emissions: number | null; mainfuel: string | null; + lexiscore: number | null; } export type NonIntrusiveSurveyNotes = InferModel< diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx index 2eae693..91c81ef 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx @@ -28,6 +28,7 @@ interface Props { const TERMINAL_STATUSES = new Set(["complete", "completed", "failed", "failure", "error"]); const FAILED_STATUSES = new Set(["failed", "failure", "error"]); +const FINAL_UPLOAD_STATUSES = new Set(["complete", "needs_review"]); export default function OnboardingProgress({ taskId, @@ -40,9 +41,37 @@ export default function OnboardingProgress({ const [data, setData] = useState(null); const [uploadStatus, setUploadStatus] = useState(null); const [fetchError, setFetchError] = useState(false); + const [finalizeError, setFinalizeError] = useState(null); const intervalRef = useRef | null>(null); const combineFiredRef = useRef(false); - const redirectedRef = useRef(false); + const finalizeFiredRef = useRef(false); + const refreshedRef = useRef(false); + + async function fireFinalize() { + try { + const res = await fetch( + `/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/finalize`, + { method: "POST" } + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + const msg = + body?.detail || body?.error || `Finalize failed (${res.status})`; + setFinalizeError(msg); + finalizeFiredRef.current = false; + } + } catch (err) { + console.error("Failed to trigger finalize:", err); + setFinalizeError(err instanceof Error ? err.message : "Network error"); + finalizeFiredRef.current = false; + } + } + + function retryFinalize() { + setFinalizeError(null); + finalizeFiredRef.current = true; + fireFinalize(); + } useEffect(() => { async function poll() { @@ -67,12 +96,16 @@ export default function OnboardingProgress({ if (uploadRes.ok) { const upload: UploadStatus = await uploadRes.json(); setUploadStatus(upload); - if (upload.status === "awaiting_review" && !redirectedRef.current) { - redirectedRef.current = true; + + if (upload.status === "awaiting_review" && !finalizeFiredRef.current) { + finalizeFiredRef.current = true; + fireFinalize(); + } + + if (FINAL_UPLOAD_STATUSES.has(upload.status) && !refreshedRef.current) { + refreshedRef.current = true; if (intervalRef.current) clearInterval(intervalRef.current); - router.push( - `/portfolio/${portfolioSlug}/bulk-upload/${uploadId}/confirm-matches` - ); + router.refresh(); return; } } @@ -85,6 +118,7 @@ export default function OnboardingProgress({ poll(); intervalRef.current = setInterval(poll, 3000); return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [taskId, portfolioId, portfolioSlug, uploadId, router]); if (fetchError) return null; @@ -105,7 +139,7 @@ export default function OnboardingProgress({ const isFailed = FAILED_STATUSES.has(data.status.toLowerCase()); const isCombining = taskDone && !isFailed && uploadStatus?.status === "combining"; - const isAwaitingReview = + const isImporting = taskDone && !isFailed && uploadStatus?.status === "awaiting_review"; return ( @@ -141,14 +175,30 @@ export default function OnboardingProgress({ Combining results… )} - {isAwaitingReview && ( - - - Ready for review + {isImporting && ( + + + Importing to portfolio… )} + {finalizeError && ( +
+
+

Import failed

+

{finalizeError}

+
+ +
+ )} + {isDomnaUser && ( r.flags.includes(filter)); - - const basePath = `/portfolio/${slug}/bulk-upload/${uploadId}/confirm-matches`; - const pageStart = data.total === 0 ? 0 : offset + 1; - const pageEnd = Math.min(offset + data.rows.length, data.total); - const hasPrev = offset > 0; - const hasNext = offset + limit < data.total; - const prevOffset = Math.max(0, offset - limit); - const nextOffset = offset + limit; - - return ( -
-
- - All ({data.total}) - - - Missing ({data.flags_summary.missing}) - - - Duplicates ({data.flags_summary.duplicates}) - -
- -
- - - - - - - - - - - - - - {rows.length === 0 && ( - - - - )} - {rows.map((row) => ( - - - - - - - - - - ))} - -
Internal RefInput AddressUPRNMatched AddressScoreFlagsActions
- No rows match this filter. -
{row.internal_reference ?? "—"}{row.input_address || "—"}{row.uprn ?? "—"}{row.matched_address ?? "—"} - - {scoreChipLabel(row.score_bucket)} - - -
- {row.flags.map((f) => ( - - {f === "missing" ? "Missing" : "Duplicate"} - - ))} - {row.flags.length === 0 && } -
-
- -
-
- -
- - Showing {pageStart}–{pageEnd} of {data.total} - -
- {hasPrev ? ( - - Prev - - ) : ( - - Prev - - )} - {hasNext ? ( - - Next - - ) : ( - - Next - - )} -
-
-
- ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/confirm-matches/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/confirm-matches/page.tsx deleted file mode 100644 index a12826a..0000000 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/confirm-matches/page.tsx +++ /dev/null @@ -1,113 +0,0 @@ -"use server"; - -import { db } from "@/app/db/db"; -import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads"; -import { eq } from "drizzle-orm"; -import { getServerSession } from "next-auth"; -import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { redirect, notFound } from "next/navigation"; -import { cookies, headers } from "next/headers"; -import Link from "next/link"; -import { ArrowLeftIcon } from "@heroicons/react/24/outline"; -import ConfirmMatchesClient, { - CombinedResultsResponse, -} from "./ConfirmMatchesClient"; - -const DEFAULT_LIMIT = 100; - -export default async function ConfirmMatchesPage(props: { - params: Promise<{ slug: string; uploadId: string }>; - searchParams: Promise<{ offset?: string; limit?: string; filter?: string }>; -}) { - const { slug, uploadId } = await props.params; - const search = await props.searchParams; - const session = await getServerSession(AuthOptions); - if (!session) redirect("/login"); - - const [upload] = await db - .select() - .from(bulkAddressUploads) - .where(eq(bulkAddressUploads.id, uploadId)) - .limit(1); - - if (!upload) notFound(); - if (upload.status !== "awaiting_review") { - redirect(`/portfolio/${slug}/bulk-upload/${uploadId}`); - } - - const offset = Math.max(0, parseInt(search.offset ?? "0", 10) || 0); - const limit = Math.max(1, Math.min(500, parseInt(search.limit ?? `${DEFAULT_LIMIT}`, 10) || DEFAULT_LIMIT)); - const filter = search.filter === "missing" || search.filter === "duplicate" ? search.filter : "all"; - - const h = await headers(); - const host = h.get("host"); - const proto = h.get("x-forwarded-proto") ?? "http"; - const cookieStore = await cookies(); - const cookieHeader = cookieStore.getAll().map((c) => `${c.name}=${c.value}`).join("; "); - - const url = `${proto}://${host}/api/portfolio/${upload.portfolioId}/bulk-uploads/${uploadId}/combined-results?offset=${offset}&limit=${limit}`; - - let data: CombinedResultsResponse | null = null; - let fetchError: string | null = null; - try { - const res = await fetch(url, { headers: { Cookie: cookieHeader }, cache: "no-store" }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - const upstreamStatus = body?.upstreamStatus; - const upstreamBody = body?.upstreamBody; - fetchError = `Failed to load results (${res.status})${upstreamStatus ? ` · upstream ${upstreamStatus}` : ""}${upstreamBody ? ` · ${upstreamBody}` : ""}`; - console.error("Confirm-matches fetch error:", { status: res.status, body }); - } else { - data = (await res.json()) as CombinedResultsResponse; - } - } catch (err) { - console.error("Failed to fetch combined-results:", err); - fetchError = `Failed to load results · ${err instanceof Error ? err.message : String(err)}`; - } - - return ( -
- - - Back to upload - - -
-

- Review matches -

-

- {upload.filename} -

- {data && ( -

- {data.total} addresses ·{" "} - {data.flags_summary.duplicates} duplicates ·{" "} - {data.flags_summary.missing} missing ·{" "} - {data.flags_summary.matched} matched -

- )} -
- - {fetchError && ( -
- {fetchError} -
- )} - - {data && ( - - )} -
- ); -} diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx index 438b9ad..fb96edd 100644 --- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/page.tsx @@ -62,11 +62,11 @@ const STATUS_CONFIG = { cta: false, }, awaiting_review: { - icon: CheckCircleIcon, - iconBg: "bg-green-50", - iconColor: "text-green-500", - title: "Ready for review", - body: "Your matches are ready. Review and confirm before finalising onboarding.", + icon: ArrowPathIcon, + iconBg: "bg-blue-50", + iconColor: "text-blue-500", + title: "Importing addresses…", + body: "Matches ready, writing into your portfolio.", cta: false, }, complete: { @@ -77,6 +77,14 @@ const STATUS_CONFIG = { body: "All addresses have been imported into your portfolio.", cta: false, }, + needs_review: { + icon: ExclamationCircleIcon, + iconBg: "bg-amber-50", + iconColor: "text-amber-500", + title: "Imported with issues", + body: "Some addresses didn't match a UPRN or matched the same UPRN as another row. Open the properties list to fix them manually.", + cta: false, + }, failed: { icon: ExclamationCircleIcon, iconBg: "bg-red-50", @@ -181,14 +189,14 @@ export default async function BulkUploadDetailPage(props: { /> )} - {statusKey === "awaiting_review" && ( - - Review matches + Open properties - + )} diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index 5e34583..239a09d 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -314,7 +314,7 @@ export default function PropertyTable({ () => { const init: VisibilityState = {}; OPTIONAL_COLUMN_IDS.forEach((id) => { - init[id] = false; + init[id] = id === "lexiscore"; }); return init; }, diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx index 7ef022e..11595e4 100644 --- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx @@ -158,6 +158,7 @@ export const OPTIONAL_COLUMN_IDS = [ "totalFloorArea", "co2Emissions", "mainfuel", + "lexiscore", ] as const; export type OptionalColumnId = (typeof OPTIONAL_COLUMN_IDS)[number]; @@ -170,6 +171,7 @@ const OPTIONAL_COLUMN_LABELS: Record = { totalFloorArea: "Floor Area (m²)", co2Emissions: "CO₂ Emissions", mainfuel: "Main Fuel", + lexiscore: "Match confidence", }; export { OPTIONAL_COLUMN_LABELS }; @@ -455,6 +457,23 @@ const optionalColumns: ColumnDef[] = [ return label ? {label} : ; }, }, + { + id: "lexiscore", + accessorKey: "lexiscore", + header: () =>
Match
, + cell: ({ row }) => { + const score = row.original.lexiscore; + if (score == null) return ; + const bucket = score >= 0.85 ? "High" : score >= 0.65 ? "Medium" : "Low"; + const cls = + bucket === "High" + ? "bg-green-50 text-green-700" + : bucket === "Medium" + ? "bg-amber-50 text-amber-700" + : "bg-red-50 text-red-700"; + return {bucket}; + }, + }, ]; export const columns: ColumnDef[] = [ diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index 8d7ff89..e3676c8 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -710,7 +710,8 @@ export async function getProperties( epc.is_expired AS "epcIsExpired", epc.total_floor_area AS "totalFloorArea", epc.co2_emissions AS "co2Emissions", - epc.mainfuel AS mainfuel + epc.mainfuel AS mainfuel, + p.lexiscore AS lexiscore FROM property p LEFT JOIN property_targets t ON t.property_id = p.id @@ -751,7 +752,8 @@ export async function getProperties( epc.is_expired, epc.total_floor_area, epc.co2_emissions, - epc.mainfuel + epc.mainfuel, + p.lexiscore LIMIT ${limit} OFFSET ${offset}; `);