save working progress

This commit is contained in:
Jun-te Kim 2026-04-24 15:57:30 +00:00
parent 9f8ea85fed
commit c3cc123a6f
11 changed files with 299 additions and 501 deletions

View file

@ -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": [

View file

@ -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<string, unknown>[];
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<Record<string, unknown>>(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<string, number>();
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 }
);
}

View file

@ -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<string, unknown>[];
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<Record<string, unknown>>(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<string, number>();
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 }
);
}
}

View file

@ -385,6 +385,7 @@ export interface PropertyWithRelations extends Record<string, unknown> {
totalFloorArea: number | null;
co2Emissions: number | null;
mainfuel: string | null;
lexiscore: number | null;
}
export type NonIntrusiveSurveyNotes = InferModel<

View file

@ -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<TaskData | null>(null);
const [uploadStatus, setUploadStatus] = useState<UploadStatus | null>(null);
const [fetchError, setFetchError] = useState(false);
const [finalizeError, setFinalizeError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | 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
</span>
)}
{isAwaitingReview && (
<span className="flex items-center gap-1 text-green-600 font-semibold">
<span className="w-1.5 h-1.5 rounded-full bg-green-500" />
Ready for review
{isImporting && (
<span className="flex items-center gap-1 text-blue-500">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
Importing to portfolio
</span>
)}
</div>
{finalizeError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 flex items-start gap-3">
<div className="flex-1">
<p className="font-semibold">Import failed</p>
<p className="text-red-600 mt-0.5 break-words">{finalizeError}</p>
</div>
<button
type="button"
onClick={retryFinalize}
className="shrink-0 px-2 py-1 rounded-md bg-red-600 text-white font-semibold hover:bg-red-700 transition-colors"
>
Retry
</button>
</div>
)}
{isDomnaUser && (
<Link
href={`/portfolio/${portfolioSlug}/settings/logs`}

View file

@ -1,197 +0,0 @@
"use client";
import Link from "next/link";
export interface CombinedResultRow {
row_index: number;
input_address: string;
internal_reference: string | null;
uprn: string | null;
matched_address: string | null;
lexiscore: number | null;
score_bucket: "high" | "med" | "low" | null;
flags: ("duplicate" | "missing")[];
}
export interface CombinedResultsResponse {
task_id: string;
total: number;
offset: number;
limit: number;
flags_summary: { duplicates: number; missing: number; matched: number };
rows: CombinedResultRow[];
}
interface Props {
data: CombinedResultsResponse;
slug: string;
uploadId: string;
filter: "all" | "missing" | "duplicate";
offset: number;
limit: number;
}
function scoreChipClasses(bucket: CombinedResultRow["score_bucket"]): string {
if (bucket === "high") return "bg-green-50 text-green-700 border-green-200";
if (bucket === "med") return "bg-amber-50 text-amber-700 border-amber-200";
if (bucket === "low") return "bg-red-50 text-red-700 border-red-200";
return "bg-gray-50 text-gray-400 border-gray-200";
}
function scoreChipLabel(bucket: CombinedResultRow["score_bucket"]): string {
if (bucket === "high") return "High";
if (bucket === "med") return "Medium";
if (bucket === "low") return "Low";
return "—";
}
function flagPillClasses(flag: "duplicate" | "missing"): string {
return flag === "missing"
? "bg-red-50 text-red-700 border border-red-200"
: "bg-amber-50 text-amber-700 border border-amber-200";
}
function tabClasses(active: boolean): string {
return active
? "px-4 py-2 rounded-xl text-sm font-semibold bg-[#14163d] text-white"
: "px-4 py-2 rounded-xl text-sm font-medium text-gray-500 hover:bg-gray-100";
}
export default function ConfirmMatchesClient({
data,
slug,
uploadId,
filter,
offset,
limit,
}: Props) {
const rows =
filter === "all"
? data.rows
: data.rows.filter((r) => 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 (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Link href={`${basePath}?offset=${offset}&limit=${limit}`} className={tabClasses(filter === "all")}>
All ({data.total})
</Link>
<Link
href={`${basePath}?offset=0&limit=${limit}&filter=missing`}
className={tabClasses(filter === "missing")}
>
Missing ({data.flags_summary.missing})
</Link>
<Link
href={`${basePath}?offset=0&limit=${limit}&filter=duplicate`}
className={tabClasses(filter === "duplicate")}
>
Duplicates ({data.flags_summary.duplicates})
</Link>
</div>
<div className="bg-white border border-gray-100 rounded-2xl overflow-hidden shadow-sm">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-xs text-gray-500 uppercase tracking-wider">
<tr>
<th className="text-left px-4 py-3">Internal Ref</th>
<th className="text-left px-4 py-3">Input Address</th>
<th className="text-left px-4 py-3">UPRN</th>
<th className="text-left px-4 py-3">Matched Address</th>
<th className="text-left px-4 py-3">Score</th>
<th className="text-left px-4 py-3">Flags</th>
<th className="text-left px-4 py-3">Actions</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-400">
No rows match this filter.
</td>
</tr>
)}
{rows.map((row) => (
<tr key={row.row_index} className="border-t border-gray-100">
<td className="px-4 py-3 text-gray-600">{row.internal_reference ?? "—"}</td>
<td className="px-4 py-3 text-gray-900">{row.input_address || "—"}</td>
<td className="px-4 py-3 font-mono text-xs text-gray-700">{row.uprn ?? "—"}</td>
<td className="px-4 py-3 text-gray-600">{row.matched_address ?? "—"}</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold border ${scoreChipClasses(row.score_bucket)}`}
>
{scoreChipLabel(row.score_bucket)}
</span>
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{row.flags.map((f) => (
<span
key={f}
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${flagPillClasses(f)}`}
>
{f === "missing" ? "Missing" : "Duplicate"}
</span>
))}
{row.flags.length === 0 && <span className="text-xs text-gray-400"></span>}
</div>
</td>
<td className="px-4 py-3">
<button
type="button"
disabled
title="Coming soon"
className="px-3 py-1.5 rounded-lg text-xs font-semibold bg-gray-100 text-gray-400 cursor-not-allowed"
>
Advanced ARA search
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between text-sm text-gray-500">
<span>
Showing {pageStart}{pageEnd} of {data.total}
</span>
<div className="flex items-center gap-2">
{hasPrev ? (
<Link
href={`${basePath}?offset=${prevOffset}&limit=${limit}${filter !== "all" ? `&filter=${filter}` : ""}`}
className="px-3 py-1.5 rounded-lg bg-white border border-gray-200 text-sm font-medium hover:bg-gray-50"
>
Prev
</Link>
) : (
<span className="px-3 py-1.5 rounded-lg bg-gray-50 border border-gray-100 text-sm text-gray-300">
Prev
</span>
)}
{hasNext ? (
<Link
href={`${basePath}?offset=${nextOffset}&limit=${limit}${filter !== "all" ? `&filter=${filter}` : ""}`}
className="px-3 py-1.5 rounded-lg bg-[#14163d] text-white text-sm font-semibold hover:opacity-90"
>
Next
</Link>
) : (
<span className="px-3 py-1.5 rounded-lg bg-gray-50 border border-gray-100 text-sm text-gray-300">
Next
</span>
)}
</div>
</div>
</div>
);
}

View file

@ -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 (
<div className="max-w-6xl mx-auto px-6 py-10">
<Link
href={`/portfolio/${slug}/bulk-upload/${uploadId}`}
className="inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-700 transition-colors mb-8"
>
<ArrowLeftIcon className="h-4 w-4" />
Back to upload
</Link>
<div className="mb-8">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-widest mb-1">
Review matches
</p>
<h1 className="text-3xl font-extrabold text-gray-900 tracking-tight mb-1">
{upload.filename}
</h1>
{data && (
<p className="text-sm text-gray-500">
{data.total} addresses ·{" "}
<span className="text-amber-600 font-semibold">{data.flags_summary.duplicates}</span> duplicates ·{" "}
<span className="text-red-600 font-semibold">{data.flags_summary.missing}</span> missing ·{" "}
<span className="text-green-600 font-semibold">{data.flags_summary.matched}</span> matched
</p>
)}
</div>
{fetchError && (
<div className="bg-red-50 border border-red-100 rounded-xl p-4 text-sm text-red-700">
{fetchError}
</div>
)}
{data && (
<ConfirmMatchesClient
data={data}
slug={slug}
uploadId={uploadId}
filter={filter}
offset={offset}
limit={limit}
/>
)}
</div>
);
}

View file

@ -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" && (
<Link
href={`/portfolio/${slug}/bulk-upload/${uploadId}/confirm-matches`}
{(statusKey === "needs_review" || statusKey === "complete") && (
<a
href={`/portfolio/${slug}`}
className="mt-4 inline-flex items-center gap-2 px-5 py-2 rounded-xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold hover:opacity-90 transition-opacity"
>
Review matches
Open properties
<ArrowRightIcon className="h-4 w-4" />
</Link>
</a>
)}
</div>
</div>

View file

@ -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;
},

View file

@ -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<OptionalColumnId, string> = {
totalFloorArea: "Floor Area (m²)",
co2Emissions: "CO₂ Emissions",
mainfuel: "Main Fuel",
lexiscore: "Match confidence",
};
export { OPTIONAL_COLUMN_LABELS };
@ -455,6 +457,23 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
return label ? <Pill>{label}</Pill> : <span className="text-slate-300 text-xs"></span>;
},
},
{
id: "lexiscore",
accessorKey: "lexiscore",
header: () => <div className="text-xs">Match</div>,
cell: ({ row }) => {
const score = row.original.lexiscore;
if (score == null) return <span className="text-slate-300 text-xs"></span>;
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 <Pill className={cls}>{bucket}</Pill>;
},
},
];
export const columns: ColumnDef<PropertyWithRelations>[] = [

View file

@ -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};
`);