mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-22 08:48:34 +00:00
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:
parent
ee22764e06
commit
8b99e7029c
2 changed files with 428 additions and 41 deletions
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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() })
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue