feat(portfolio): separate properties without a UPRN into their own section

The portfolio landing page listed every property in one table, mixing in the
ones that never matched to an Ordnance Survey UPRN. Those unmatched properties
need a different workflow (fix the address/postcode, then re-run the address→
UPRN search), so surface them separately above the main table.

This change does the separation only:
- getUnmatchedProperties(portfolioId): portfolio properties with uprn IS NULL
  (excluding soft-deleted), falling back to the user-inputted address/postcode
  when the canonical fields are empty.
- UnmatchedProperties: an amber "needs attention" card listing address +
  postcode per row, with a placeholder "Search UPRN" action where the advanced
  ARA search will hang off. Renders nothing when every property is matched, so
  healthy portfolios stay uncluttered.
- Portfolio page renders the section above the existing PropertyTable, which is
  left exactly as-is.

Follow-up (the actual feature): make address/postcode editable per row and wire
the "Search UPRN" button to the advanced ARA search.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-07-06 16:54:47 +00:00
parent c861d4a0b0
commit 4fefec7cca
3 changed files with 138 additions and 0 deletions

View file

@ -1,4 +1,6 @@
import PropertyTable from "../components/PropertyTable";
import UnmatchedProperties from "../components/UnmatchedProperties";
import { getUnmatchedProperties } from "@/lib/properties/unmatched";
export const revalidate = 60;
@ -11,8 +13,11 @@ export default async function Page(props: {
const params = await props.params;
const portfolioId = params.slug;
const unmatched = await getUnmatchedProperties(portfolioId);
return (
<>
<UnmatchedProperties properties={unmatched} />
<PropertyTable portfolioId={portfolioId} />
</>
);

View file

@ -0,0 +1,86 @@
import {
ExclamationTriangleIcon,
MagnifyingGlassIcon,
} from "@heroicons/react/24/outline";
import type { UnmatchedProperty } from "@/lib/properties/unmatched";
// The "needs attention" workspace that separates properties with no UPRN from
// the matched portfolio table. Read-only for now; the per-row action is where
// the advanced ARA search (edit address/postcode → search UPRN) will hang off.
// Renders nothing when every property is matched, so a healthy portfolio stays
// uncluttered.
export default function UnmatchedProperties({
properties,
}: {
properties: UnmatchedProperty[];
}) {
if (properties.length === 0) return null;
const count = properties.length;
return (
<section
aria-label="Properties without a UPRN"
className="mb-6 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&apos;t be
matched to an Ordnance Survey address automatically. Check the address
and postcode, then run an advanced search to find the right match.
</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">
<button
type="button"
disabled
title="Advanced ARA search — coming soon"
className="inline-flex cursor-not-allowed items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-400"
>
<MagnifyingGlassIcon className="h-3.5 w-3.5" />
Search UPRN
</button>
</div>
</li>
))}
</ul>
</div>
</section>
);
}

View 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,
}));
}