From 8b99e7029c942e9bf458132ad58c96904b5a925e Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Jul 2026 16:19:18 +0000 Subject: [PATCH] feat(bulk-upload): two-tab review with pre-finalise Addresses/UPRN confirmation (ADR-0057) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../bulk-upload/[uploadId]/AddressesPanel.tsx | 320 ++++++++++++++++++ .../[uploadId]/OnboardingProgress.tsx | 149 +++++--- 2 files changed, 428 insertions(+), 41 deletions(-) create mode 100644 src/app/portfolio/[slug]/(portfolio)/bulk-upload/[uploadId]/AddressesPanel.tsx 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 ? ( + + ) : ( + <> + + + + )} +
    + + + + + 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. + + + +
    +
    + + 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" + /> +
    +
    +
    + + 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" + /> +
    + +
    + + {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 ( +
    • + +
    • + ); + })} +
    + )} +
    +
    + )} +
    + + + {save.error && ( +

    + {save.error.message} +

    + )} + + +
    +
    +
    +
  • + ); +} 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 && ( +
    + + +
    )} - {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() })