feat(bulk-upload): two-tab review with pre-finalise Addresses/UPRN confirmation (ADR-0057)

At awaiting_review, when address2uprn flagged rows, OnboardingProgress now
shows two tabs: Classification (existing verify/unknown/ordering panels) and
Addresses. The Addresses tab (AddressesPanel) lists each flagged combiner row
with the OS Places matcher (reused search → residential candidates) and a
"No UPRN" action, saving corrections via useSaveUprnCorrection. Finalise is
gated on unresolvedAddresses === 0 with a matching disabled reason; the tab
carries a badge of the unresolved count.

tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-07-07 16:19:18 +00:00
parent ee22764e06
commit 8b99e7029c
2 changed files with 428 additions and 41 deletions

View file

@ -0,0 +1,320 @@
"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&apos;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 "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>
);
}

View file

@ -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,
@ -82,6 +84,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
@ -90,6 +95,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) {
@ -152,11 +164,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">
@ -224,47 +242,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) && (
@ -284,11 +349,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() })