diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/AddressesPanel.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/AddressesPanel.tsx
new file mode 100644
index 00000000..6f99ab1e
--- /dev/null
+++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/AddressesPanel.tsx
@@ -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 (
+
+
+
+ Addresses that need a UPRN
+
+
+ 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.
+
+
+
+ {flagged.length === 0 ? (
+
Nothing to resolve.
+ ) : (
+
+ {flagged.map((row) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+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(null);
+ const [selected, setSelected] = useState(null);
+ const [source, setSource] = useState<"cache" | "live" | null>(null);
+ const [searching, setSearching] = useState(false);
+ const [searchError, setSearchError] = useState(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 (
+
+
+
+ {row.address || "(no address)"}
+
+
+ {row.postcode || "no postcode"}
+ {row.lexiscore != null ? ` · score ${row.lexiscore.toFixed(2)}` : ""}
+ {" · "}
+ {statusLabel(row.status)}
+
+ {row.resolved && (
+
+
+ {row.markedNoUprn
+ ? "Marked: no UPRN"
+ : `UPRN ${row.resolvedUprn}${row.resolvedAddress ? ` · ${row.resolvedAddress}` : ""}`}
+
+ )}
+
+
+
+ {row.resolved ? (
+ setOpen(true)}
+ className="text-xs text-gray-500 underline underline-offset-2 hover:text-gray-800"
+ >
+ Change
+
+ ) : (
+ <>
+ 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"
+ >
+
+ Find UPRN
+
+
+ No UPRN
+
+ >
+ )}
+
+
+
+
+
+ Match to a UPRN
+
+ Confirm the postcode and pick the matching address. Only residential
+ addresses from Ordnance Survey are shown, so the UPRN is always valid.
+
+
+
+
+
+
+ Address
+
+ 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"
+ />
+
+
+
+
+ Postcode
+
+ 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"
+ />
+
+
+
+ {searching ? "Searching…" : "Search"}
+
+
+
+ {searchError &&
{searchError}
}
+
+ {residential && (
+
+
+
+ Residential addresses
+
+ {source === "cache" && (
+
+ from cache
+
+ )}
+
+
+ {residential.length === 0 ? (
+
+ No residential addresses found for that postcode.
+
+ ) : (
+
+ {residential.map((c) => {
+ const { label } = describeCandidate(c);
+ const isSelected = selected?.uprn === c.uprn;
+ return (
+
+ 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"
+ }`}
+ >
+
+
+
+ {c.address}
+
+
+ UPRN {c.uprn} · {label}
+ {c.alreadyInPortfolio ? " · already in portfolio" : ""}
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ )}
+
+
+
+ {save.error && (
+
+ {save.error.message}
+
+ )}
+ setOpen(false)}
+ className="rounded-lg px-4 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-100"
+ >
+ Cancel
+
+
+ {save.isPending ? "Assigning…" : "Assign UPRN"}
+
+
+
+
+
+ );
+}
diff --git a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx
index f5dc7cd0..fe48a58b 100644
--- a/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx
+++ b/src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/OnboardingProgress.tsx
@@ -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 (
@@ -224,47 +242,94 @@ export default function OnboardingProgress({
)}
- {needsVerify && sample && (
-
+ {/* 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 && (
+
+ 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
+
+ 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 && (
+
+ {unresolvedAddresses}
+
+ )}
+
+
)}
- {isAwaitingReview && unknownTotal > 0 && (
-
- )}
-
- {needsOrdering && orderingSamples.length > 0 && (
-
- {orderingSamples.map(([count, orderSample], i) => (
-
+ {needsVerify && sample && (
+ 1
- ? `Part group ${i + 1}`
- : undefined
- }
+ verified={verifyAck}
+ stepLabel={showStepNumbers ? "Step 1" : undefined}
portfolioId={portfolioId}
uploadId={uploadId}
/>
- ))}
-
+ )}
+
+ {isAwaitingReview && unknownTotal > 0 && (
+
+ )}
+
+ {needsOrdering && orderingSamples.length > 0 && (
+
+ {orderingSamples.map(([count, orderSample], i) => (
+ 1
+ ? `Part group ${i + 1}`
+ : undefined
+ }
+ portfolioId={portfolioId}
+ uploadId={uploadId}
+ />
+ ))}
+
+ )}
+ >
+ )}
+
+ {showAddressTab && reviewTab === "addresses" && (
+
)}
{(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() })