mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-19 08:53:01 +00:00
Merge pull request #367 from Hestia-Homes/feature/uprn-confirmation-onboarding
Confirm UPRNs before finalise: two-tab bulk-upload review + retire Unmatched tab (ADR-0057)
This commit is contained in:
commit
4c65bca0c3
22 changed files with 13706 additions and 54 deletions
|
|
@ -0,0 +1,43 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { upsertUprnCorrection } from "@/lib/bulkUpload/addressMatches";
|
||||
|
||||
// Confirm one flagged combiner row (ADR-0057): either assign a UPRN chosen from
|
||||
// OS Places, or assert the address has none. Keyed by `source_row_id` (there is
|
||||
// no property.id yet — the finaliser creates identities). Upsert semantics, so
|
||||
// re-confirming overwrites. The confirmation is overlaid onto the combiner CSV
|
||||
// at dispatchFinaliser.
|
||||
const AssignSchema = z.object({
|
||||
sourceRowId: z.string().min(1),
|
||||
uprn: z.string().regex(/^\d+$/, "UPRN must be numeric"),
|
||||
address: z.string().min(1),
|
||||
postcode: z.string().min(1),
|
||||
});
|
||||
const NoUprnSchema = z.object({
|
||||
sourceRowId: z.string().min(1),
|
||||
markedNoUprn: z.literal(true),
|
||||
});
|
||||
const BodySchema = z.union([NoUprnSchema, AssignSchema]);
|
||||
|
||||
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 parsed = BodySchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await upsertUprnCorrection(uploadId, parsed.data, session.user?.email ?? undefined);
|
||||
return NextResponse.json({ ok: true }, { status: 200 });
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { getFlaggedAddresses } from "@/lib/bulkUpload/addressMatches";
|
||||
|
||||
// Read-only: the combiner rows address2uprn could not confidently match
|
||||
// (unmatched / ambiguous_duplicate / invalid_postcode), joined with any saved
|
||||
// correction. Drives the pre-finalise "Addresses" review tab (ADR-0057); the
|
||||
// Finalise gate blocks until every flagged row is resolved.
|
||||
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 flagged = await getFlaggedAddresses(uploadId);
|
||||
const unresolved = flagged.filter((f) => !f.resolved).length;
|
||||
return NextResponse.json({ flagged, unresolved }, { status: 200 });
|
||||
}
|
||||
|
|
@ -35,6 +35,14 @@ export async function POST(
|
|||
return NextResponse.json({ error: "Combiner not finished" }, { status: 409 });
|
||||
case "missing_task":
|
||||
return NextResponse.json({ error: "Upload has no task to finalise" }, { status: 409 });
|
||||
case "unresolved_addresses":
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `${result.count} address${result.count === 1 ? "" : "es"} still need a UPRN. Resolve them on the Addresses tab before finalising.`,
|
||||
unresolvedAddresses: result.count,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
case "wrong_state":
|
||||
return NextResponse.json(
|
||||
{ error: `Upload not ready to finalize (state: ${result.current})` },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/app/db/db";
|
||||
import { property } from "@/app/db/schema/property";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
// Assign a chosen Ordnance Survey address (from the postcode search) to an
|
||||
// existing property: writes the canonical uprn/address/postcode and keeps what
|
||||
// the user typed as the user-inputted fallback. Keyed by (property, portfolio)
|
||||
// so a caller can only touch a property in a portfolio they addressed. The
|
||||
// candidate is guaranteed residential + UPRN-bearing by the picker UI.
|
||||
const patchSchema = z.object({
|
||||
uprn: z.string().regex(/^\d+$/, "UPRN must be numeric"),
|
||||
address: z.string().trim().min(1, "Address is required"),
|
||||
postcode: z.string().trim().min(1, "Postcode is required"),
|
||||
// What the user typed in the edit fields — kept as the raw fallback, mirroring
|
||||
// how the bulk finaliser stores user_inputted_* alongside the matched values.
|
||||
userInputtedAddress: z.string().trim().optional(),
|
||||
userInputtedPostcode: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
const code = (err as { code?: string })?.code;
|
||||
const message = (err as { message?: string })?.message ?? "";
|
||||
return code === "23505" || message.includes("uq_property_portfolio_uprn");
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ portfolioId: string; propertyId: string }> },
|
||||
) {
|
||||
const session = await getServerSession(AuthOptions);
|
||||
if (!session?.user?.email) {
|
||||
return NextResponse.json({ error: "Unauthorised" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { portfolioId, propertyId } = await props.params;
|
||||
if (!/^\d+$/.test(portfolioId) || !/^\d+$/.test(propertyId)) {
|
||||
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = patchSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const { uprn, address, postcode, userInputtedAddress, userInputtedPostcode } =
|
||||
parsed.data;
|
||||
|
||||
try {
|
||||
const updated = await db
|
||||
.update(property)
|
||||
.set({
|
||||
uprn: BigInt(uprn),
|
||||
address,
|
||||
postcode,
|
||||
...(userInputtedAddress !== undefined ? { userInputtedAddress } : {}),
|
||||
...(userInputtedPostcode !== undefined ? { userInputtedPostcode } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(property.id, BigInt(propertyId)),
|
||||
eq(property.portfolioId, BigInt(portfolioId)),
|
||||
),
|
||||
)
|
||||
.returning({ id: property.id });
|
||||
|
||||
if (updated.length === 0) {
|
||||
return NextResponse.json({ error: "Property not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (isUniqueViolation(err)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"That UPRN is already assigned to another property in this portfolio.",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
console.error("PATCH /properties/[propertyId] error:", err);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
15
src/app/db/migrations/0265_uprn_corrections.sql
Normal file
15
src/app/db/migrations/0265_uprn_corrections.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
CREATE TABLE "bulk_upload_uprn_corrections" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"upload_id" uuid NOT NULL,
|
||||
"source_row_id" text NOT NULL,
|
||||
"uprn" text,
|
||||
"address" text,
|
||||
"postcode" text,
|
||||
"marked_no_uprn" boolean DEFAULT false NOT NULL,
|
||||
"user_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "bulk_upload_uprn_corrections_upload_row_unique" UNIQUE("upload_id","source_row_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "bulk_upload_uprn_corrections" ADD CONSTRAINT "bulk_upload_uprn_corrections_upload_id_bulk_address_uploads_id_fk" FOREIGN KEY ("upload_id") REFERENCES "public"."bulk_address_uploads"("id") ON DELETE cascade ON UPDATE no action;
|
||||
12120
src/app/db/migrations/meta/0265_snapshot.json
Normal file
12120
src/app/db/migrations/meta/0265_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1849,6 +1849,13 @@
|
|||
"when": 1783430551377,
|
||||
"tag": "0264_yummy_master_chief",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 265,
|
||||
"version": "7",
|
||||
"when": 1783446767021,
|
||||
"tag": "0265_uprn_corrections",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
46
src/app/db/schema/bulk_upload_uprn_corrections.ts
Normal file
46
src/app/db/schema/bulk_upload_uprn_corrections.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { pgTable, uuid, text, boolean, timestamp, unique } from "drizzle-orm/pg-core";
|
||||
|
||||
import { bulkAddressUploads } from "./bulk_address_uploads";
|
||||
|
||||
// User confirmations of a combiner row's UPRN, captured on the pre-finalise
|
||||
// "Addresses" review tab (ADR-0057). address2uprn withholds ambiguous / no-UPRN
|
||||
// matches; the user resolves each flagged row via OS Places here, keyed by the
|
||||
// combiner CSV's `source_row_id` (there is no `property.id` yet — the finaliser
|
||||
// creates identities). At dispatchFinaliser these corrections are overlaid onto
|
||||
// the combiner CSV before the finaliser reads it, so the confirmed UPRN drives
|
||||
// both the identity insert and property_overrides in one pass — no post-finalise
|
||||
// backfill (which is why the portfolio "Unmatched" tab is retired).
|
||||
//
|
||||
// A row is resolved when it has a numeric `uprn` OR `markedNoUprn = true` (the
|
||||
// user asserts no UPRN exists — an accepted terminal state; the finaliser
|
||||
// leaves it as a no-UPRN property).
|
||||
export const bulkUploadUprnCorrections = pgTable(
|
||||
"bulk_upload_uprn_corrections",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
uploadId: uuid("upload_id")
|
||||
.notNull()
|
||||
.references(() => bulkAddressUploads.id, { onDelete: "cascade" }),
|
||||
// The combiner CSV join key (synthetic UUID minted at start-address-matching).
|
||||
sourceRowId: text("source_row_id").notNull(),
|
||||
// The confirmed UPRN; null when markedNoUprn.
|
||||
uprn: text("uprn"),
|
||||
address: text("address"),
|
||||
postcode: text("postcode"),
|
||||
// The user asserts this address has no UPRN — resolved without one.
|
||||
markedNoUprn: boolean("marked_no_uprn").notNull().default(false),
|
||||
userId: text("user_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => ({
|
||||
// One correction per combiner row — the confirm action upserts on this.
|
||||
uploadRowUnique: unique("bulk_upload_uprn_corrections_upload_row_unique").on(
|
||||
t.uploadId,
|
||||
t.sourceRowId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MagnifyingGlassIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/app/shadcn_components/ui/dialog";
|
||||
import { describeCandidate } from "@/lib/postcodeSearch/model";
|
||||
import { searchAddresses, type SearchCandidate } from "@/lib/properties/client";
|
||||
import { useSaveUprnCorrection, type FlaggedAddress } from "@/lib/bulkUpload/client";
|
||||
|
||||
// The pre-finalise "Addresses" review tab (ADR-0057). Lists the combiner rows
|
||||
// address2uprn could not confidently match; the user resolves each via Ordnance
|
||||
// Survey (residential candidates only → a guaranteed valid UPRN) or marks it as
|
||||
// having no UPRN. Confirmations are saved as corrections (keyed by
|
||||
// source_row_id) and overlaid onto the combiner CSV at finalise.
|
||||
export default function AddressesPanel({
|
||||
flagged,
|
||||
portfolioId,
|
||||
uploadId,
|
||||
}: {
|
||||
flagged: FlaggedAddress[];
|
||||
portfolioId: string;
|
||||
uploadId: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-amber-200 bg-amber-50/40 p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-800">
|
||||
Addresses that need a UPRN
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-gray-500">
|
||||
We couldn't confidently match these to Ordnance Survey. Pick the
|
||||
right address — or mark it as having no UPRN — so onboarding creates the
|
||||
correct property.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{flagged.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">Nothing to resolve.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-amber-100 rounded-lg border border-amber-100 bg-white">
|
||||
{flagged.map((row) => (
|
||||
<AddressRow
|
||||
key={row.sourceRowId}
|
||||
row={row}
|
||||
portfolioId={portfolioId}
|
||||
uploadId={uploadId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case "ambiguous_duplicate":
|
||||
return "Ambiguous match";
|
||||
case "low_score":
|
||||
return "Low-confidence match";
|
||||
case "unmatched":
|
||||
return "No match";
|
||||
case "invalid_postcode":
|
||||
return "Invalid postcode";
|
||||
case "error":
|
||||
return "Match error";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function AddressRow({
|
||||
row,
|
||||
portfolioId,
|
||||
uploadId,
|
||||
}: {
|
||||
row: FlaggedAddress;
|
||||
portfolioId: string;
|
||||
uploadId: string;
|
||||
}) {
|
||||
const save = useSaveUprnCorrection(portfolioId, uploadId);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [address, setAddress] = useState(row.address ?? "");
|
||||
const [postcode, setPostcode] = useState(row.postcode ?? "");
|
||||
// null = not searched yet; [] = searched, no residential matches.
|
||||
const [residential, setResidential] = useState<SearchCandidate[] | null>(null);
|
||||
const [selected, setSelected] = useState<SearchCandidate | null>(null);
|
||||
const [source, setSource] = useState<"cache" | "live" | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
|
||||
async function runSearch() {
|
||||
setSearching(true);
|
||||
setSearchError(null);
|
||||
setResidential(null);
|
||||
setSelected(null);
|
||||
try {
|
||||
const result = await searchAddresses(portfolioId, postcode);
|
||||
// Residential only — we never assign a non-residential UPRN.
|
||||
setResidential(result.candidates.filter((c) => c.selectable));
|
||||
setSource(result.source);
|
||||
} catch (e) {
|
||||
setSearchError(e instanceof Error ? e.message : "Search failed.");
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onAssign() {
|
||||
if (!selected) return;
|
||||
save.mutate(
|
||||
{
|
||||
sourceRowId: row.sourceRowId,
|
||||
uprn: selected.uprn,
|
||||
address: selected.address,
|
||||
postcode: selected.postcode,
|
||||
},
|
||||
{ onSuccess: () => setOpen(false) },
|
||||
);
|
||||
}
|
||||
|
||||
function onNoUprn() {
|
||||
save.mutate({ sourceRowId: row.sourceRowId, markedNoUprn: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="flex items-start justify-between gap-3 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-gray-800" title={row.address}>
|
||||
{row.address || "(no address)"}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{row.postcode || "no postcode"}
|
||||
{row.lexiscore != null ? ` · score ${row.lexiscore.toFixed(2)}` : ""}
|
||||
{" · "}
|
||||
<span className="font-medium text-amber-700">{statusLabel(row.status)}</span>
|
||||
</p>
|
||||
{row.resolved && (
|
||||
<p className="mt-1 inline-flex items-center gap-1 text-[11px] font-medium text-green-700">
|
||||
<CheckCircleIcon className="h-3.5 w-3.5" />
|
||||
{row.markedNoUprn
|
||||
? "Marked: no UPRN"
|
||||
: `UPRN ${row.resolvedUprn}${row.resolvedAddress ? ` · ${row.resolvedAddress}` : ""}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{row.resolved ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="text-xs text-gray-500 underline underline-offset-2 hover:text-gray-800"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-xs font-semibold text-amber-800 transition-colors hover:bg-amber-100"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-3.5 w-3.5" />
|
||||
Find UPRN
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNoUprn}
|
||||
disabled={save.isPending}
|
||||
className="text-xs text-gray-500 underline underline-offset-2 hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
No UPRN
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Match to a UPRN</DialogTitle>
|
||||
<DialogDescription>
|
||||
Confirm the postcode and pick the matching address. Only residential
|
||||
addresses from Ordnance Survey are shown, so the UPRN is always valid.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-gray-600">
|
||||
Address
|
||||
</label>
|
||||
<input
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="e.g. 20 Brenchley Road"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs font-semibold text-gray-600">
|
||||
Postcode
|
||||
</label>
|
||||
<input
|
||||
value={postcode}
|
||||
onChange={(e) => setPostcode(e.target.value)}
|
||||
placeholder="e.g. BR5 2TD"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && postcode.trim() && !searching)
|
||||
runSearch();
|
||||
}}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runSearch}
|
||||
disabled={!postcode.trim() || searching}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:bg-amber-300"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-4 w-4" />
|
||||
{searching ? "Searching…" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{searchError && <p className="text-xs text-red-600">{searchError}</p>}
|
||||
|
||||
{residential && (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-gray-600">
|
||||
Residential addresses
|
||||
</p>
|
||||
{source === "cache" && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-gray-400">
|
||||
from cache
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto rounded-lg border border-gray-100">
|
||||
{residential.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-gray-400">
|
||||
No residential addresses found for that postcode.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-50">
|
||||
{residential.map((c) => {
|
||||
const { label } = describeCandidate(c);
|
||||
const isSelected = selected?.uprn === c.uprn;
|
||||
return (
|
||||
<li key={c.uprn}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(c)}
|
||||
className={`flex w-full items-start gap-2 px-3 py-2 text-left text-sm transition-colors ${
|
||||
isSelected ? "bg-amber-50" : "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<CheckCircleIcon
|
||||
className={`mt-0.5 h-4 w-4 shrink-0 ${
|
||||
isSelected ? "text-amber-600" : "text-gray-200"
|
||||
}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className="block truncate text-gray-800"
|
||||
title={c.address}
|
||||
>
|
||||
{c.address}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-gray-500">
|
||||
UPRN {c.uprn} · {label}
|
||||
{c.alreadyInPortfolio ? " · already in portfolio" : ""}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{save.error && (
|
||||
<p className="mr-auto self-center text-xs text-red-600">
|
||||
{save.error.message}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-lg px-4 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAssign}
|
||||
disabled={!selected || save.isPending}
|
||||
className="rounded-lg bg-amber-600 px-4 py-2 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:bg-amber-300"
|
||||
>
|
||||
{save.isPending ? "Assigning…" : "Assign UPRN"}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
|||
import Link from "next/link";
|
||||
import { ArrowRightIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
useAddressMatches,
|
||||
useBulkUploadProgress,
|
||||
useConfirmMultiEntryOrdering,
|
||||
useConfirmVerification,
|
||||
|
|
@ -14,6 +15,7 @@ import {
|
|||
useSampleClassifications,
|
||||
type SampleClassifications,
|
||||
} from "@/lib/bulkUpload/client";
|
||||
import AddressesPanel from "./AddressesPanel";
|
||||
import {
|
||||
partLabel,
|
||||
isPermutation,
|
||||
|
|
@ -69,6 +71,9 @@ export default function OnboardingProgress({
|
|||
});
|
||||
const combine = useRequestCombine(portfolioId, uploadId);
|
||||
const finalize = useFinalize(portfolioId, uploadId);
|
||||
const [reviewTab, setReviewTab] = useState<"classification" | "addresses">(
|
||||
"classification",
|
||||
);
|
||||
|
||||
// Read-only classifications for the multi-entry sample (issue #298). Fetched
|
||||
// only once a sample exists at awaiting_review. Hook stays above the early
|
||||
|
|
@ -77,6 +82,13 @@ export default function OnboardingProgress({
|
|||
progress.data?.upload.status === "awaiting_review" &&
|
||||
!!progress.data.upload.multiEntrySummary?.sample;
|
||||
const classifications = useSampleClassifications(portfolioId, uploadId, sampleReady);
|
||||
// Combiner rows address2uprn could not confidently match (ADR-0057). Fetched
|
||||
// once at awaiting_review; drives the Addresses tab + the Finalise gate.
|
||||
const addressMatches = useAddressMatches(
|
||||
portfolioId,
|
||||
uploadId,
|
||||
progress.data?.upload.status === "awaiting_review",
|
||||
);
|
||||
|
||||
if (progress.isError) return null;
|
||||
if (!progress.data) {
|
||||
|
|
@ -139,11 +151,17 @@ export default function OnboardingProgress({
|
|||
(n, descriptions) => n + descriptions.length,
|
||||
0,
|
||||
);
|
||||
// Flagged addresses (no confident UPRN) must all be resolved before finalise,
|
||||
// else a withheld row lands as an unintended no-UPRN property (ADR-0057).
|
||||
const flaggedCount = addressMatches.data?.flagged.length ?? 0;
|
||||
const unresolvedAddresses = addressMatches.data?.unresolved ?? 0;
|
||||
const showAddressTab = isAwaitingReview && flaggedCount > 0;
|
||||
const canFinalize =
|
||||
isAwaitingReview &&
|
||||
(!needsVerify || verifyAck) &&
|
||||
(!needsOrdering || orderingConfirmed) &&
|
||||
unknownTotal === 0;
|
||||
unknownTotal === 0 &&
|
||||
unresolvedAddresses === 0;
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-3">
|
||||
|
|
@ -211,47 +229,94 @@ export default function OnboardingProgress({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{needsVerify && sample && (
|
||||
<VerifyClassificationPanel
|
||||
sample={sample}
|
||||
classifications={classifications.data?.classifications ?? {}}
|
||||
verified={verifyAck}
|
||||
stepLabel={showStepNumbers ? "Step 1" : undefined}
|
||||
portfolioId={portfolioId}
|
||||
uploadId={uploadId}
|
||||
/>
|
||||
{/* Two-tab review at awaiting_review: classification checks | address/UPRN
|
||||
confirmation (ADR-0057). The Addresses tab only appears when there are
|
||||
flagged rows; otherwise the classification panels render as before. */}
|
||||
{showAddressTab && (
|
||||
<div className="flex gap-4 border-b border-gray-200 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReviewTab("classification")}
|
||||
className={`-mb-px border-b-2 px-1 pb-2 transition-colors ${
|
||||
reviewTab === "classification"
|
||||
? "border-[#14163d] font-medium text-[#14163d]"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
Classification
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReviewTab("addresses")}
|
||||
className={`-mb-px flex items-center gap-1.5 border-b-2 px-1 pb-2 transition-colors ${
|
||||
reviewTab === "addresses"
|
||||
? "border-[#14163d] font-medium text-[#14163d]"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
Addresses
|
||||
{unresolvedAddresses > 0 && (
|
||||
<span className="rounded-full bg-amber-500 px-1.5 text-[10px] font-semibold text-white">
|
||||
{unresolvedAddresses}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAwaitingReview && unknownTotal > 0 && (
|
||||
<UnresolvedClassificationsPanel
|
||||
unknown={unknownByField}
|
||||
portfolioId={portfolioId}
|
||||
uploadId={uploadId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{needsOrdering && orderingSamples.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{orderingSamples.map(([count, orderSample], i) => (
|
||||
<MultiEntryOrderingPanel
|
||||
key={count}
|
||||
sample={orderSample}
|
||||
ordering={upload.multiEntryOrdering ?? null}
|
||||
{(!showAddressTab || reviewTab === "classification") && (
|
||||
<>
|
||||
{needsVerify && sample && (
|
||||
<VerifyClassificationPanel
|
||||
sample={sample}
|
||||
classifications={classifications.data?.classifications ?? {}}
|
||||
// Number the panels only when there's also a verify step or more
|
||||
// than one count, so a lone ordering panel stays unnumbered.
|
||||
stepLabel={
|
||||
showStepNumbers
|
||||
? `Step ${i + 2}`
|
||||
: orderingSamples.length > 1
|
||||
? `Part group ${i + 1}`
|
||||
: undefined
|
||||
}
|
||||
verified={verifyAck}
|
||||
stepLabel={showStepNumbers ? "Step 1" : undefined}
|
||||
portfolioId={portfolioId}
|
||||
uploadId={uploadId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAwaitingReview && unknownTotal > 0 && (
|
||||
<UnresolvedClassificationsPanel
|
||||
unknown={unknownByField}
|
||||
portfolioId={portfolioId}
|
||||
uploadId={uploadId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{needsOrdering && orderingSamples.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{orderingSamples.map(([count, orderSample], i) => (
|
||||
<MultiEntryOrderingPanel
|
||||
key={count}
|
||||
sample={orderSample}
|
||||
ordering={upload.multiEntryOrdering ?? null}
|
||||
classifications={classifications.data?.classifications ?? {}}
|
||||
// Number the panels only when there's also a verify step or more
|
||||
// than one count, so a lone ordering panel stays unnumbered.
|
||||
stepLabel={
|
||||
showStepNumbers
|
||||
? `Step ${i + 2}`
|
||||
: orderingSamples.length > 1
|
||||
? `Part group ${i + 1}`
|
||||
: undefined
|
||||
}
|
||||
portfolioId={portfolioId}
|
||||
uploadId={uploadId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showAddressTab && reviewTab === "addresses" && (
|
||||
<AddressesPanel
|
||||
flagged={addressMatches.data?.flagged ?? []}
|
||||
portfolioId={portfolioId}
|
||||
uploadId={uploadId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(canRunCombiner || isAwaitingReview) && (
|
||||
|
|
@ -271,11 +336,13 @@ export default function OnboardingProgress({
|
|||
isPending={finalize.isPending}
|
||||
disabled={!canFinalize}
|
||||
disabledReason={
|
||||
unknownTotal > 0
|
||||
? `Resolve ${unknownTotal} unclassified description${unknownTotal === 1 ? "" : "s"} first`
|
||||
: needsVerify && !verifyAck
|
||||
? "Verify the classification first"
|
||||
: "Confirm the building-part order first"
|
||||
unresolvedAddresses > 0
|
||||
? `Resolve ${unresolvedAddresses} address${unresolvedAddresses === 1 ? "" : "es"} on the Addresses tab first`
|
||||
: unknownTotal > 0
|
||||
? `Resolve ${unknownTotal} unclassified description${unknownTotal === 1 ? "" : "s"} first`
|
||||
: needsVerify && !verifyAck
|
||||
? "Verify the classification first"
|
||||
: "Confirm the building-part order first"
|
||||
}
|
||||
onClick={() =>
|
||||
finalize.mutate(undefined, { onSuccess: () => router.refresh() })
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ export default async function Page(props: {
|
|||
const params = await props.params;
|
||||
const portfolioId = params.slug;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PropertyTable portfolioId={portfolioId} />
|
||||
</>
|
||||
);
|
||||
// UPRN matching is now confirmed during bulk-upload onboarding (ADR-0057), so
|
||||
// properties arrive already matched — the portfolio-level "Unmatched" tab is
|
||||
// retired. The tab components (PortfolioTabs / UnmatchedProperties /
|
||||
// getUnmatchedProperties) are left in place for now, pending a follow-up that
|
||||
// reconciles legacy no-UPRN properties onboarded before this feature.
|
||||
return <PropertyTable portfolioId={portfolioId} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import {
|
|||
SparklesIcon,
|
||||
BuildingOfficeIcon,
|
||||
ShieldCheckIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
getPropertyMeta,
|
||||
getConditionReport,
|
||||
|
|
@ -67,6 +69,43 @@ export default async function BuildingPassportHome(props: {
|
|||
}) {
|
||||
const params = await props.params;
|
||||
const propertyMeta = await getPropertyMeta(params.propertyId);
|
||||
|
||||
// A property with no UPRN isn't matched to an Ordnance Survey address yet, so
|
||||
// the whole UPRN-keyed passport (spatial data, installed measures, EPC) has
|
||||
// nothing to show — and getSpatialData(BigInt(null)) would 500. Surface a
|
||||
// clear "not matched" state and link back to the portfolio instead.
|
||||
if (!propertyMeta.uprn) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-6 py-16">
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-8 text-center shadow-sm">
|
||||
<ExclamationTriangleIcon className="mx-auto h-10 w-10 text-amber-500" />
|
||||
<h1 className="mt-4 text-lg font-bold text-amber-900">
|
||||
Not matched to a UPRN yet
|
||||
</h1>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-amber-700">
|
||||
{propertyMeta.address ? (
|
||||
<>
|
||||
<span className="font-medium">{propertyMeta.address}</span>{" "}
|
||||
hasn't{" "}
|
||||
</>
|
||||
) : (
|
||||
"This property hasn't "
|
||||
)}
|
||||
been matched to an Ordnance Survey address, so its building passport
|
||||
isn't available yet. Match it to a UPRN from the portfolio to
|
||||
unlock the passport.
|
||||
</p>
|
||||
<Link
|
||||
href={`/portfolio/${params.slug}`}
|
||||
className="mt-6 inline-flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
||||
>
|
||||
Back to portfolio
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const conditionReport = await getConditionReport(params.propertyId);
|
||||
const spatial = await getSpatialData(propertyMeta.uprn);
|
||||
const installedMeasures = await getInstalledMeasuresByUprn(Number(propertyMeta.uprn));
|
||||
|
|
|
|||
242
src/app/portfolio/[slug]/components/MatchAddress.tsx
Normal file
242
src/app/portfolio/[slug]/components/MatchAddress.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
CheckCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/app/shadcn_components/ui/dialog";
|
||||
import { describeCandidate } from "@/lib/postcodeSearch/model";
|
||||
import {
|
||||
searchAddresses,
|
||||
useAssignUprn,
|
||||
type SearchCandidate,
|
||||
} from "@/lib/properties/client";
|
||||
import type { UnmatchedProperty } from "@/lib/properties/unmatched";
|
||||
|
||||
// Row action for an unmatched property: edit the address, confirm the postcode,
|
||||
// search Ordnance Survey, and pick from the RESIDENTIAL addresses in that
|
||||
// postcode. Picking assigns that candidate's UPRN — every candidate is a real
|
||||
// OS address, so this guarantees a valid, residential UPRN. On success the
|
||||
// property leaves the "Needs attention" tab (the page re-fetches).
|
||||
export default function MatchAddress({
|
||||
property,
|
||||
portfolioId,
|
||||
}: {
|
||||
property: UnmatchedProperty;
|
||||
portfolioId: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [address, setAddress] = useState(property.address ?? "");
|
||||
const [postcode, setPostcode] = useState(property.postcode ?? "");
|
||||
// null = not searched yet; [] = searched, no residential matches.
|
||||
const [residential, setResidential] = useState<SearchCandidate[] | null>(null);
|
||||
const [selected, setSelected] = useState<SearchCandidate | null>(null);
|
||||
const [source, setSource] = useState<"cache" | "live" | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
|
||||
const assign = useAssignUprn(portfolioId, property.id);
|
||||
|
||||
async function runSearch() {
|
||||
setSearching(true);
|
||||
setSearchError(null);
|
||||
setResidential(null);
|
||||
setSelected(null);
|
||||
try {
|
||||
const result = await searchAddresses(portfolioId, postcode);
|
||||
// Residential only — we never assign a non-residential UPRN.
|
||||
setResidential(result.candidates.filter((c) => c.selectable));
|
||||
setSource(result.source);
|
||||
} catch (e) {
|
||||
setSearchError(e instanceof Error ? e.message : "Search failed.");
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onAssign() {
|
||||
if (!selected) return;
|
||||
assign.mutate(
|
||||
{
|
||||
uprn: selected.uprn,
|
||||
address: selected.address,
|
||||
postcode: selected.postcode,
|
||||
userInputtedAddress: address.trim() || undefined,
|
||||
userInputtedPostcode: postcode.trim() || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-xs font-semibold text-amber-800 transition-colors hover:bg-amber-100"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-3.5 w-3.5" />
|
||||
Find UPRN
|
||||
</button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Match to a UPRN</DialogTitle>
|
||||
<DialogDescription>
|
||||
Confirm the postcode and pick the matching address. Only
|
||||
residential addresses from Ordnance Survey are shown, so the UPRN
|
||||
is always valid.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-semibold text-gray-600">
|
||||
Address
|
||||
</label>
|
||||
<input
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
placeholder="e.g. 20 Brenchley Road"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs font-semibold text-gray-600">
|
||||
Postcode
|
||||
</label>
|
||||
<input
|
||||
value={postcode}
|
||||
onChange={(e) => setPostcode(e.target.value)}
|
||||
placeholder="e.g. BR5 2TD"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && postcode.trim() && !searching)
|
||||
runSearch();
|
||||
}}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runSearch}
|
||||
disabled={!postcode.trim() || searching}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:bg-amber-300"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-4 w-4" />
|
||||
{searching ? "Searching…" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{searchError && <p className="text-xs text-red-600">{searchError}</p>}
|
||||
|
||||
{residential && (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-gray-600">
|
||||
Residential addresses
|
||||
</p>
|
||||
{source === "cache" && (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-gray-400">
|
||||
from cache
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto rounded-lg border border-gray-100">
|
||||
{residential.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-gray-400">
|
||||
No residential addresses found for that postcode.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-50">
|
||||
{residential.map((c) => {
|
||||
const { label } = describeCandidate(c);
|
||||
const disabled = c.alreadyInPortfolio;
|
||||
const isSelected = selected?.uprn === c.uprn;
|
||||
return (
|
||||
<li key={c.uprn}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setSelected(c)}
|
||||
className={`flex w-full items-start gap-2 px-3 py-2 text-left text-sm transition-colors ${
|
||||
disabled
|
||||
? "cursor-not-allowed text-gray-400"
|
||||
: isSelected
|
||||
? "bg-amber-50"
|
||||
: "hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<CheckCircleIcon
|
||||
className={`mt-0.5 h-4 w-4 shrink-0 ${
|
||||
isSelected ? "text-amber-600" : "text-gray-200"
|
||||
}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className="block truncate text-gray-800"
|
||||
title={c.address}
|
||||
>
|
||||
{c.address}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-gray-500">
|
||||
UPRN {c.uprn} · {label}
|
||||
{c.alreadyInPortfolio
|
||||
? " · already in portfolio"
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{assign.error && (
|
||||
<p className="mr-auto self-center text-xs text-red-600">
|
||||
{assign.error.message}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-lg px-4 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAssign}
|
||||
disabled={!selected || assign.isPending}
|
||||
className="rounded-lg bg-amber-600 px-4 py-2 text-sm font-bold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:bg-amber-300"
|
||||
>
|
||||
{assign.isPending ? "Assigning…" : "Assign UPRN"}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
src/app/portfolio/[slug]/components/PortfolioTabs.tsx
Normal file
77
src/app/portfolio/[slug]/components/PortfolioTabs.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
// Two-tab shell for the portfolio landing view: the main properties table, and
|
||||
// a "Needs attention" tab for properties that aren't matched to a UPRN yet.
|
||||
// Both panels stay mounted (hidden with CSS) so the table keeps its filter /
|
||||
// pagination state when the user flicks between tabs.
|
||||
export default function PortfolioTabs({
|
||||
needsAttentionCount,
|
||||
properties,
|
||||
needsAttention,
|
||||
}: {
|
||||
needsAttentionCount: number;
|
||||
properties: React.ReactNode;
|
||||
needsAttention: React.ReactNode;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"properties" | "needs-attention">("properties");
|
||||
|
||||
// Nothing unmatched → no tab strip at all, just the properties table.
|
||||
if (needsAttentionCount === 0) return <>{properties}</>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center gap-1 border-b border-gray-200">
|
||||
<TabButton
|
||||
active={tab === "properties"}
|
||||
onClick={() => setTab("properties")}
|
||||
>
|
||||
Properties
|
||||
</TabButton>
|
||||
<TabButton
|
||||
active={tab === "needs-attention"}
|
||||
onClick={() => setTab("needs-attention")}
|
||||
>
|
||||
<ExclamationTriangleIcon className="h-4 w-4" />
|
||||
Needs attention
|
||||
{needsAttentionCount > 0 && (
|
||||
<span className="inline-flex min-w-[1.25rem] items-center justify-center rounded-full bg-amber-100 px-1.5 text-[11px] font-bold text-amber-800">
|
||||
{needsAttentionCount}
|
||||
</span>
|
||||
)}
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
<div className={tab === "properties" ? "" : "hidden"}>{properties}</div>
|
||||
<div className={tab === "needs-attention" ? "" : "hidden"}>
|
||||
{needsAttention}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`-mb-px flex items-center gap-1.5 border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors ${
|
||||
active
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-gray-500 hover:text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from "@heroicons/react/24/outline";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { HomeIcon } from "@heroicons/react/24/outline";
|
||||
import BulkUploadComingSoonModal from "@/app/components/portfolio/BulkUploadComingSoonModal";
|
||||
import { sapToEpc } from "@/app/utils";
|
||||
import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns";
|
||||
import { PropertyWithRelations } from "@/app/db/schema/property";
|
||||
|
|
@ -308,6 +309,7 @@ export default function PropertyTable({
|
|||
}) {
|
||||
const router = useRouter();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [bulkImportOpen, setBulkImportOpen] = useState(false);
|
||||
|
||||
const [committedAddress, setCommittedAddress] = useState("");
|
||||
const [committedPostcode, setCommittedPostcode] = useState("");
|
||||
|
|
@ -655,25 +657,28 @@ export default function PropertyTable({
|
|||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled
|
||||
className="flex items-start gap-3 rounded-lg p-3"
|
||||
className="flex items-start gap-3 cursor-pointer rounded-lg p-3"
|
||||
onSelect={() => setBulkImportOpen(true)}
|
||||
>
|
||||
<DocumentArrowUpIcon className="h-[18px] w-[18px] mt-0.5 text-gray-400 shrink-0" />
|
||||
<DocumentArrowUpIcon className="h-[18px] w-[18px] mt-0.5 text-gray-700 shrink-0" />
|
||||
<span className="flex-1">
|
||||
<span className="block text-sm font-semibold text-gray-500">
|
||||
<span className="block text-sm font-semibold text-gray-900">
|
||||
Bulk excel import
|
||||
</span>
|
||||
<span className="block text-xs text-slate-400">
|
||||
Upload a spreadsheet of addresses
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide bg-amber-100 text-amber-800 rounded-full px-2 py-0.5 shrink-0">
|
||||
Soon
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<BulkUploadComingSoonModal
|
||||
isOpen={bulkImportOpen}
|
||||
onClose={() => setBulkImportOpen(false)}
|
||||
portfolioId={portfolioId}
|
||||
/>
|
||||
|
||||
{/* Run modelling */}
|
||||
<button
|
||||
onClick={() => router.push(`/portfolio/${portfolioId}/modelling/run`)}
|
||||
|
|
|
|||
92
src/app/portfolio/[slug]/components/UnmatchedProperties.tsx
Normal file
92
src/app/portfolio/[slug]/components/UnmatchedProperties.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import {
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { UnmatchedProperty } from "@/lib/properties/unmatched";
|
||||
import MatchAddress from "./MatchAddress";
|
||||
|
||||
// The "Needs attention" tab: properties with no UPRN, separated out of the main
|
||||
// table until they're matched. Each row lets the user correct the address /
|
||||
// postcode (UPRN matching is a later step).
|
||||
export default function UnmatchedProperties({
|
||||
properties,
|
||||
portfolioId,
|
||||
}: {
|
||||
properties: UnmatchedProperty[];
|
||||
portfolioId: string;
|
||||
}) {
|
||||
const count = properties.length;
|
||||
|
||||
if (count === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-100 bg-white px-6 py-16 text-center shadow-sm">
|
||||
<CheckCircleIcon className="mx-auto h-9 w-9 text-emerald-500" />
|
||||
<p className="mt-3 text-sm font-semibold text-gray-700">
|
||||
All properties are matched
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
Every property has a UPRN — nothing needs attention.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Properties without a UPRN"
|
||||
className="overflow-hidden rounded-2xl border border-amber-200 bg-amber-50 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start gap-3 border-b border-amber-100 px-6 py-4">
|
||||
<ExclamationTriangleIcon className="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-bold text-amber-900">
|
||||
Properties without a UPRN
|
||||
</h2>
|
||||
<span className="inline-flex items-center rounded-full bg-amber-200/70 px-2 py-0.5 text-xs font-semibold text-amber-800">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 max-w-xl text-xs text-amber-700">
|
||||
{count === 1 ? "This property" : "These properties"} couldn't be
|
||||
matched by AraAddressMatcher automatically. Double check the{" "}
|
||||
{count === 1 ? "address" : "addresses"} and Ara will try again with
|
||||
AraAddressMatcherPlus.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white">
|
||||
<div className="grid grid-cols-12 gap-3 border-b border-gray-100 bg-gray-50/80 px-6 py-2 text-[11px] font-semibold uppercase tracking-wider text-gray-500">
|
||||
<div className="col-span-6">Address</div>
|
||||
<div className="col-span-3">Postcode</div>
|
||||
<div className="col-span-3 text-right">Action</div>
|
||||
</div>
|
||||
|
||||
<ul className="divide-y divide-gray-50">
|
||||
{properties.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="grid grid-cols-12 items-center gap-3 px-6 py-3"
|
||||
>
|
||||
<p
|
||||
className="col-span-6 truncate text-sm text-gray-800"
|
||||
title={p.address ?? undefined}
|
||||
>
|
||||
{p.address ?? (
|
||||
<span className="italic text-gray-400">No address</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="col-span-3 text-sm text-gray-600">
|
||||
{p.postcode ?? <span className="text-gray-400">—</span>}
|
||||
</p>
|
||||
<div className="col-span-3 flex justify-end">
|
||||
<MatchAddress property={p} portfolioId={portfolioId} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -739,6 +739,9 @@ export async function getPropertiesCount(
|
|||
${epcJoins}
|
||||
${planJoin}
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
-- Unmatched (no-UPRN) properties live in the "Needs attention" tab until
|
||||
-- resolved, so they're excluded from the main table + its count.
|
||||
AND p.uprn IS NOT NULL
|
||||
${combinedWhere}
|
||||
`);
|
||||
|
||||
|
|
@ -831,6 +834,9 @@ export async function getProperties(
|
|||
ON epc.property_id = p.id
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
-- Unmatched (no-UPRN) properties live in the "Needs attention" tab until
|
||||
-- resolved, so they're excluded from the main table.
|
||||
AND p.uprn IS NOT NULL
|
||||
${combinedWhere}
|
||||
LIMIT ${limit} OFFSET ${offset};
|
||||
`);
|
||||
|
|
|
|||
246
src/lib/bulkUpload/addressMatches.ts
Normal file
246
src/lib/bulkUpload/addressMatches.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import { db } from "@/app/db/db";
|
||||
import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads";
|
||||
import { bulkUploadUprnCorrections } from "@/app/db/schema/bulk_upload_uprn_corrections";
|
||||
import { createRetrofitDataS3Client } from "@/app/utils/s3";
|
||||
import { SOURCE_ROW_ID_COLUMN } from "./s3Keys";
|
||||
|
||||
// Columns address2uprn writes onto each combiner row (ADR-0057). `status` is the
|
||||
// per-row outcome; a withheld/ambiguous/no-match row has an empty `uprn`.
|
||||
const UPRN_COL = "address2uprn_uprn";
|
||||
const STATUS_COL = "address2uprn_status";
|
||||
const LEXISCORE_COL = "address2uprn_lexiscore";
|
||||
|
||||
// A combiner row is confidently matched only when address2uprn kept a UPRN AND
|
||||
// did not flag it. Everything else — unmatched, ambiguous_duplicate,
|
||||
// invalid_postcode, error, or a legacy row with no status but no UPRN — needs
|
||||
// the user to resolve it on the Addresses tab before finalise.
|
||||
function isConfidentlyMatched(row: Record<string, string>): boolean {
|
||||
const uprn = (row[UPRN_COL] ?? "").trim();
|
||||
const status = (row[STATUS_COL] ?? "").trim();
|
||||
if (!uprn) return false;
|
||||
return status === "" || status === "matched";
|
||||
}
|
||||
|
||||
export interface FlaggedAddressRow {
|
||||
sourceRowId: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
status: string;
|
||||
lexiscore: number | null;
|
||||
// The candidate address2uprn found but did not trust (null when none).
|
||||
suggestedUprn: string | null;
|
||||
// The user's confirmation (from bulk_upload_uprn_corrections), if any.
|
||||
resolvedUprn: string | null;
|
||||
resolvedAddress: string | null;
|
||||
markedNoUprn: boolean;
|
||||
// Resolved = user picked a UPRN OR asserted there is none.
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
function parseS3Uri(uri: string): { bucket: string; key: string } {
|
||||
const url = new URL(uri); // s3://bucket/key...
|
||||
const key = url.pathname.startsWith("/") ? url.pathname.slice(1) : url.pathname;
|
||||
if (!url.hostname || !key) throw new Error(`Malformed S3 URI: ${uri}`);
|
||||
return { bucket: url.hostname, key };
|
||||
}
|
||||
|
||||
async function readCombinerRows(s3Uri: string): Promise<Record<string, string>[]> {
|
||||
const { bucket, key } = parseS3Uri(s3Uri);
|
||||
const s3 = createRetrofitDataS3Client();
|
||||
const result = await s3.getObject({ Bucket: bucket, Key: key }).promise();
|
||||
const body = result.Body?.toString("utf-8");
|
||||
if (!body) return [];
|
||||
const wb = XLSX.read(body, { type: "string" });
|
||||
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||
// Every cell as a string so a numeric UPRN never arrives as a float.
|
||||
return XLSX.utils.sheet_to_json<Record<string, string>>(sheet, { defval: "", raw: false });
|
||||
}
|
||||
|
||||
function buildAddress(row: Record<string, string>): string {
|
||||
return ["Address 1", "Address 2", "Address 3"]
|
||||
.map((c) => (row[c] ?? "").trim())
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The combiner rows that need user confirmation, joined with any saved
|
||||
* correction. Reads the combiner CSV from `combinedOutputS3Uri` (ADR-0057).
|
||||
* Returns [] until the combiner has run.
|
||||
*/
|
||||
export async function getFlaggedAddresses(
|
||||
uploadId: string,
|
||||
): Promise<FlaggedAddressRow[]> {
|
||||
const [row] = await db
|
||||
.select({ combinedOutputS3Uri: bulkAddressUploads.combinedOutputS3Uri })
|
||||
.from(bulkAddressUploads)
|
||||
.where(eq(bulkAddressUploads.id, uploadId));
|
||||
if (!row?.combinedOutputS3Uri) return [];
|
||||
|
||||
const [rows, corrections] = await Promise.all([
|
||||
readCombinerRows(row.combinedOutputS3Uri),
|
||||
db
|
||||
.select()
|
||||
.from(bulkUploadUprnCorrections)
|
||||
.where(eq(bulkUploadUprnCorrections.uploadId, uploadId)),
|
||||
]);
|
||||
|
||||
const correctionByRow = new Map(corrections.map((c) => [c.sourceRowId, c]));
|
||||
|
||||
return rows
|
||||
.filter((r) => !isConfidentlyMatched(r))
|
||||
.map((r) => {
|
||||
const sourceRowId = (r[SOURCE_ROW_ID_COLUMN] ?? "").trim();
|
||||
const uprn = (r[UPRN_COL] ?? "").trim();
|
||||
const rawScore = (r[LEXISCORE_COL] ?? "").trim();
|
||||
const lexiscore = rawScore === "" ? null : Number(rawScore);
|
||||
const c = correctionByRow.get(sourceRowId);
|
||||
const resolvedUprn = c?.uprn ?? null;
|
||||
const markedNoUprn = c?.markedNoUprn ?? false;
|
||||
return {
|
||||
sourceRowId,
|
||||
address: buildAddress(r),
|
||||
postcode: (r["postcode"] ?? "").trim(),
|
||||
status: (r[STATUS_COL] ?? (uprn ? "matched" : "unmatched")).trim(),
|
||||
lexiscore: lexiscore !== null && Number.isFinite(lexiscore) ? lexiscore : null,
|
||||
suggestedUprn: uprn || null,
|
||||
resolvedUprn,
|
||||
resolvedAddress: c?.address ?? null,
|
||||
markedNoUprn,
|
||||
resolved: resolvedUprn !== null || markedNoUprn,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Count of flagged rows the user has not yet resolved — gates Finalise. */
|
||||
export async function countUnresolvedFlaggedAddresses(uploadId: string): Promise<number> {
|
||||
const flagged = await getFlaggedAddresses(uploadId);
|
||||
return flagged.filter((f) => !f.resolved).length;
|
||||
}
|
||||
|
||||
const ADDRESS_COL = "address2uprn_address";
|
||||
const OVERLAY_STATUS_CONFIRMED = "user_confirmed";
|
||||
const OVERLAY_STATUS_NO_UPRN = "user_no_uprn";
|
||||
|
||||
/**
|
||||
* Overlay the user's saved corrections onto the combiner CSV and write the
|
||||
* result to a sibling `….confirmed.csv`, returning its S3 URI (ADR-0057). The
|
||||
* finaliser reads this so a confirmed UPRN drives both the identity insert and
|
||||
* property_overrides in one pass. No corrections ⇒ the original URI is returned
|
||||
* unchanged (no rewrite). A `markedNoUprn` row keeps an empty UPRN (accepted
|
||||
* terminal state — the finaliser leaves it as a no-UPRN property).
|
||||
*/
|
||||
export async function buildConfirmedCombinerUri(
|
||||
uploadId: string,
|
||||
combinedOutputS3Uri: string,
|
||||
): Promise<string> {
|
||||
const corrections = await db
|
||||
.select()
|
||||
.from(bulkUploadUprnCorrections)
|
||||
.where(eq(bulkUploadUprnCorrections.uploadId, uploadId));
|
||||
if (corrections.length === 0) return combinedOutputS3Uri;
|
||||
const byRow = new Map(corrections.map((c) => [c.sourceRowId, c]));
|
||||
|
||||
const { bucket, key } = parseS3Uri(combinedOutputS3Uri);
|
||||
const s3 = createRetrofitDataS3Client();
|
||||
const result = await s3.getObject({ Bucket: bucket, Key: key }).promise();
|
||||
const body = result.Body?.toString("utf-8");
|
||||
if (!body) return combinedOutputS3Uri;
|
||||
|
||||
const wb = XLSX.read(body, { type: "string" });
|
||||
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||
// Array-of-arrays so every column + its order is preserved exactly; we patch
|
||||
// only the address2uprn cells of corrected rows.
|
||||
const aoa = XLSX.utils.sheet_to_json<string[]>(sheet, {
|
||||
header: 1,
|
||||
defval: "",
|
||||
raw: false,
|
||||
blankrows: false,
|
||||
});
|
||||
const headers = (aoa[0] ?? []).map((h) => String(h));
|
||||
const iRow = headers.indexOf(SOURCE_ROW_ID_COLUMN);
|
||||
const iUprn = headers.indexOf(UPRN_COL);
|
||||
const iStatus = headers.indexOf(STATUS_COL);
|
||||
const iAddr = headers.indexOf(ADDRESS_COL);
|
||||
if (iRow < 0 || iUprn < 0) return combinedOutputS3Uri; // not an address2uprn CSV
|
||||
|
||||
for (let r = 1; r < aoa.length; r++) {
|
||||
const line = aoa[r];
|
||||
const srid = String(line[iRow] ?? "").trim();
|
||||
const c = byRow.get(srid);
|
||||
if (!c) continue;
|
||||
if (c.markedNoUprn) {
|
||||
line[iUprn] = "";
|
||||
if (iStatus >= 0) line[iStatus] = OVERLAY_STATUS_NO_UPRN;
|
||||
} else {
|
||||
line[iUprn] = c.uprn ?? "";
|
||||
if (iStatus >= 0) line[iStatus] = OVERLAY_STATUS_CONFIRMED;
|
||||
if (iAddr >= 0 && c.address) line[iAddr] = c.address;
|
||||
}
|
||||
}
|
||||
|
||||
const csv = XLSX.utils.sheet_to_csv(XLSX.utils.aoa_to_sheet(aoa));
|
||||
const confirmedKey = `${key.replace(/\.csv$/i, "")}.confirmed.csv`;
|
||||
await s3
|
||||
.putObject({ Bucket: bucket, Key: confirmedKey, Body: csv, ContentType: "text/csv" })
|
||||
.promise();
|
||||
return `s3://${bucket}/${confirmedKey}`;
|
||||
}
|
||||
|
||||
export type UprnCorrectionInput =
|
||||
| {
|
||||
sourceRowId: string;
|
||||
uprn: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
markedNoUprn?: false;
|
||||
}
|
||||
| { sourceRowId: string; markedNoUprn: true };
|
||||
|
||||
/** Upsert one combiner row's confirmation (assign a UPRN, or mark no-UPRN). */
|
||||
export async function upsertUprnCorrection(
|
||||
uploadId: string,
|
||||
input: UprnCorrectionInput,
|
||||
userId?: string,
|
||||
): Promise<void> {
|
||||
const values = input.markedNoUprn
|
||||
? {
|
||||
uploadId,
|
||||
sourceRowId: input.sourceRowId,
|
||||
uprn: null,
|
||||
address: null,
|
||||
postcode: null,
|
||||
markedNoUprn: true as const,
|
||||
userId: userId ?? null,
|
||||
}
|
||||
: {
|
||||
uploadId,
|
||||
sourceRowId: input.sourceRowId,
|
||||
uprn: input.uprn,
|
||||
address: input.address,
|
||||
postcode: input.postcode,
|
||||
markedNoUprn: false as const,
|
||||
userId: userId ?? null,
|
||||
};
|
||||
|
||||
await db
|
||||
.insert(bulkUploadUprnCorrections)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
bulkUploadUprnCorrections.uploadId,
|
||||
bulkUploadUprnCorrections.sourceRowId,
|
||||
],
|
||||
set: {
|
||||
uprn: values.uprn,
|
||||
address: values.address,
|
||||
postcode: values.postcode,
|
||||
markedNoUprn: values.markedNoUprn,
|
||||
userId: values.userId,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -150,6 +150,74 @@ export function useSampleClassifications(
|
|||
});
|
||||
}
|
||||
|
||||
// One combiner row that address2uprn could not confidently match (ADR-0057),
|
||||
// plus any saved correction. Mirrors the server FlaggedAddressRow.
|
||||
export interface FlaggedAddress {
|
||||
sourceRowId: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
status: string;
|
||||
lexiscore: number | null;
|
||||
suggestedUprn: string | null;
|
||||
resolvedUprn: string | null;
|
||||
resolvedAddress: string | null;
|
||||
markedNoUprn: boolean;
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
export interface AddressMatchesView {
|
||||
flagged: FlaggedAddress[];
|
||||
unresolved: number;
|
||||
}
|
||||
|
||||
export function useAddressMatches(
|
||||
portfolioId: string,
|
||||
uploadId: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery<AddressMatchesView, Error>({
|
||||
queryKey: [...bulkUploadKeys.progress(uploadId), "address-matches"],
|
||||
enabled,
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/address-matches`,
|
||||
);
|
||||
if (!res.ok) throw await parseError(res, "Failed to load address matches.");
|
||||
const body = await res.json();
|
||||
return {
|
||||
flagged: (body.flagged ?? []) as FlaggedAddress[],
|
||||
unresolved: Number(body.unresolved ?? 0),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type SaveCorrectionInput =
|
||||
| { sourceRowId: string; uprn: string; address: string; postcode: string }
|
||||
| { sourceRowId: string; markedNoUprn: true };
|
||||
|
||||
export function useSaveUprnCorrection(portfolioId: string, uploadId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, Error, SaveCorrectionInput>({
|
||||
mutationFn: async (input) => {
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/address-corrections`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw await parseError(res, "Failed to save address.");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...bulkUploadKeys.progress(uploadId), "address-matches"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useConfirmMultiEntryOrdering(portfolioId: string, uploadId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<BulkUpload, Error, { permutations: Record<string, number[]> }>({
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ import { retrofitDataS3Bucket } from "@/app/utils/s3";
|
|||
import { SUBTASK_SERVICE } from "./types";
|
||||
import type { MultiEntrySummary } from "./multiEntry";
|
||||
import { isPermutation } from "./multiEntry";
|
||||
import {
|
||||
buildConfirmedCombinerUri,
|
||||
countUnresolvedFlaggedAddresses,
|
||||
} from "./addressMatches";
|
||||
|
||||
const REMAP_ALLOWED: ReadonlySet<BulkUploadStatus> = new Set([
|
||||
"ready_for_processing",
|
||||
|
|
@ -603,6 +607,7 @@ export type DispatchFinaliserOutcome =
|
|||
| { kind: "not_yet_combined" }
|
||||
| { kind: "wrong_state"; current: string }
|
||||
| { kind: "missing_task" }
|
||||
| { kind: "unresolved_addresses"; count: number }
|
||||
| { kind: "trigger_failed"; status: number; message: string };
|
||||
|
||||
// Dispatch the async bulk_upload_finaliser (ADR-0005). Replaces the old
|
||||
|
|
@ -631,6 +636,14 @@ export async function dispatchFinaliser(args: {
|
|||
const upload = guarded.upload;
|
||||
if (!upload.taskId) return { kind: "missing_task" };
|
||||
|
||||
// ADR-0057: every combiner row address2uprn could not confidently match must
|
||||
// be resolved on the Addresses tab (a chosen UPRN or an explicit no-UPRN)
|
||||
// before finalise — otherwise a withheld row would land as an unintended
|
||||
// no-UPRN property. Gate before the CAS claim so a blocked upload stays in
|
||||
// awaiting_review.
|
||||
const unresolved = await countUnresolvedFlaggedAddresses(args.uploadId);
|
||||
if (unresolved > 0) return { kind: "unresolved_addresses", count: unresolved };
|
||||
|
||||
// CAS: atomically claim the dispatch. Only the request that flips
|
||||
// awaiting_review → finalising proceeds; a concurrent one updates 0 rows.
|
||||
const claimed = await db
|
||||
|
|
@ -674,10 +687,18 @@ export async function dispatchFinaliser(args: {
|
|||
? `s3://${retrofitDataS3Bucket()}/${classifierCsvKey(upload.portfolioId, args.uploadId)}`
|
||||
: null;
|
||||
|
||||
// Overlay the user's confirmed UPRNs onto the combiner CSV and point the
|
||||
// finaliser at the corrected copy (ADR-0057). No corrections ⇒ the original
|
||||
// URI is returned unchanged.
|
||||
const confirmedS3Uri = await buildConfirmedCombinerUri(
|
||||
args.uploadId,
|
||||
upload.combinedOutputS3Uri!,
|
||||
);
|
||||
|
||||
const payload = {
|
||||
task_id: upload.taskId,
|
||||
sub_task_id: subTask.id,
|
||||
s3_uri: upload.combinedOutputS3Uri,
|
||||
s3_uri: confirmedS3Uri,
|
||||
portfolio_id: Number(upload.portfolioId),
|
||||
bulk_upload_id: args.uploadId,
|
||||
classifier_s3_uri: classifierS3Uri,
|
||||
|
|
|
|||
68
src/lib/properties/client.ts
Normal file
68
src/lib/properties/client.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
async function parseError(res: Response, fallback: string): Promise<Error> {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
return new Error(body?.error ?? fallback);
|
||||
}
|
||||
|
||||
// One address candidate returned by the portfolio postcode search, plus the
|
||||
// server-computed flag for candidates already assigned in this portfolio.
|
||||
export interface SearchCandidate {
|
||||
uprn: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
classificationCode: string | null;
|
||||
selectable: boolean; // residential (per OS classification)
|
||||
propertyType: string | null;
|
||||
builtForm: string | null;
|
||||
alreadyInPortfolio: boolean;
|
||||
}
|
||||
|
||||
export interface AddressSearchResult {
|
||||
postcode: string;
|
||||
source: "cache" | "live";
|
||||
candidates: SearchCandidate[];
|
||||
}
|
||||
|
||||
// Run the postcode → address-candidate search. Reuses the add-properties
|
||||
// endpoint, which read-through caches OS Places results per postcode in the
|
||||
// postcode_search table (30-day TTL) — so repeat searches of the same postcode
|
||||
// hit our DB, not the OS API. Throws with the server's { error } on failure.
|
||||
export async function searchAddresses(
|
||||
portfolioId: string,
|
||||
postcode: string,
|
||||
): Promise<AddressSearchResult> {
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/properties/search?postcode=${encodeURIComponent(postcode)}`,
|
||||
);
|
||||
if (!res.ok) throw await parseError(res, "Address search failed.");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface AssignUprnInput {
|
||||
uprn: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
userInputtedAddress?: string;
|
||||
userInputtedPostcode?: string;
|
||||
}
|
||||
|
||||
// Assign a chosen candidate's UPRN + address to a property.
|
||||
export function useAssignUprn(portfolioId: string, propertyId: string) {
|
||||
return useMutation<{ ok: true }, Error, AssignUprnInput>({
|
||||
mutationFn: async (input) => {
|
||||
const res = await fetch(
|
||||
`/api/portfolio/${portfolioId}/properties/${propertyId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw await parseError(res, "Failed to assign UPRN.");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
47
src/lib/properties/unmatched.ts
Normal file
47
src/lib/properties/unmatched.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { db } from "@/app/db/db";
|
||||
import { property } from "@/app/db/schema/property";
|
||||
import { and, asc, eq, isNull } from "drizzle-orm";
|
||||
|
||||
export interface UnmatchedProperty {
|
||||
id: string;
|
||||
address: string | null;
|
||||
postcode: string | null;
|
||||
}
|
||||
|
||||
// Properties in a portfolio that have no UPRN — i.e. were never matched to an
|
||||
// Ordnance Survey address record. We surface these separately from the main
|
||||
// property table so the customer can correct the address/postcode and re-run
|
||||
// the address→UPRN search (the "advanced ARA search", wired up in a follow-up).
|
||||
// `markedForDeletion` rows are excluded so soft-deleted properties don't show.
|
||||
export async function getUnmatchedProperties(
|
||||
portfolioId: string,
|
||||
): Promise<UnmatchedProperty[]> {
|
||||
if (!/^\d+$/.test(portfolioId)) return [];
|
||||
const pid = BigInt(portfolioId);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: property.id,
|
||||
address: property.address,
|
||||
postcode: property.postcode,
|
||||
userInputtedAddress: property.userInputtedAddress,
|
||||
userInputtedPostcode: property.userInputtedPostcode,
|
||||
})
|
||||
.from(property)
|
||||
.where(
|
||||
and(
|
||||
eq(property.portfolioId, pid),
|
||||
isNull(property.uprn),
|
||||
eq(property.markedForDeletion, false),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(property.address));
|
||||
|
||||
// Prefer the canonical address; fall back to whatever the user typed on
|
||||
// import so the row is still identifiable while it awaits a match.
|
||||
return rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
address: r.address ?? r.userInputtedAddress ?? null,
|
||||
postcode: r.postcode ?? r.userInputtedPostcode ?? null,
|
||||
}));
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue