mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
save working progress
This commit is contained in:
parent
9f8ea85fed
commit
c3cc123a6f
11 changed files with 299 additions and 501 deletions
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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`}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>[] = [
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
`);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue