feat(portfolio): move unmatched properties into a "Needs attention" tab

Reworks the separation from a card stacked above the table into a proper
second tab, and takes unmatched properties out of the main list entirely:

- PortfolioTabs: "Properties" | "Needs attention" tab shell with a count badge
  on the second tab. Both panels stay mounted (hidden with CSS) so the main
  table keeps its filter/pagination state across tab switches.
- getProperties / getPropertiesCount now exclude uprn IS NULL, so unmatched
  properties no longer appear (or count) in the main table until resolved.
  (Both functions are only used by the main table's /api/properties route.)
- UnmatchedProperties: lives in the tab; shows an "all matched" empty state
  instead of rendering nothing, and copy updated for the edit-only scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-07-07 11:17:48 +00:00
parent f1cef2e643
commit 7ac632a7aa
4 changed files with 113 additions and 14 deletions

View file

@ -1,5 +1,6 @@
import PropertyTable from "../components/PropertyTable";
import UnmatchedProperties from "../components/UnmatchedProperties";
import PortfolioTabs from "../components/PortfolioTabs";
import { getUnmatchedProperties } from "@/lib/properties/unmatched";
export const revalidate = 60;
@ -16,9 +17,12 @@ export default async function Page(props: {
const unmatched = await getUnmatchedProperties(portfolioId);
return (
<>
<UnmatchedProperties properties={unmatched} portfolioId={portfolioId} />
<PropertyTable portfolioId={portfolioId} />
</>
<PortfolioTabs
needsAttentionCount={unmatched.length}
properties={<PropertyTable portfolioId={portfolioId} />}
needsAttention={
<UnmatchedProperties properties={unmatched} portfolioId={portfolioId} />
}
/>
);
}

View file

@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// Two-tab shell for the portfolio landing view: the main properties table, and
// a "Needs attention" tab for properties that aren't matched to a UPRN yet.
// Both panels stay mounted (hidden with CSS) so the table keeps its filter /
// pagination state when the user flicks between tabs.
export default function PortfolioTabs({
needsAttentionCount,
properties,
needsAttention,
}: {
needsAttentionCount: number;
properties: React.ReactNode;
needsAttention: React.ReactNode;
}) {
const [tab, setTab] = useState<"properties" | "needs-attention">("properties");
return (
<div>
<div className="mb-4 flex items-center gap-1 border-b border-gray-200">
<TabButton
active={tab === "properties"}
onClick={() => setTab("properties")}
>
Properties
</TabButton>
<TabButton
active={tab === "needs-attention"}
onClick={() => setTab("needs-attention")}
>
<ExclamationTriangleIcon className="h-4 w-4" />
Needs attention
{needsAttentionCount > 0 && (
<span className="inline-flex min-w-[1.25rem] items-center justify-center rounded-full bg-amber-100 px-1.5 text-[11px] font-bold text-amber-800">
{needsAttentionCount}
</span>
)}
</TabButton>
</div>
<div className={tab === "properties" ? "" : "hidden"}>{properties}</div>
<div className={tab === "needs-attention" ? "" : "hidden"}>
{needsAttention}
</div>
</div>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`-mb-px flex items-center gap-1.5 border-b-2 px-4 py-2.5 text-sm font-semibold transition-colors ${
active
? "border-primary text-primary"
: "border-transparent text-gray-500 hover:text-gray-800"
}`}
>
{children}
</button>
);
}

View file

@ -1,11 +1,13 @@
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import {
ExclamationTriangleIcon,
CheckCircleIcon,
} from "@heroicons/react/24/outline";
import type { UnmatchedProperty } from "@/lib/properties/unmatched";
import EditAddress from "./EditAddress";
// The "needs attention" workspace that separates properties with no UPRN from
// the matched portfolio table. Each row lets the user correct the address /
// postcode (UPRN matching is a later step). Renders nothing when every property
// is matched, so a healthy portfolio stays uncluttered.
// The "Needs attention" tab: properties with no UPRN, separated out of the main
// table until they're matched. Each row lets the user correct the address /
// postcode (UPRN matching is a later step).
export default function UnmatchedProperties({
properties,
portfolioId,
@ -13,14 +15,26 @@ export default function UnmatchedProperties({
properties: UnmatchedProperty[];
portfolioId: string;
}) {
if (properties.length === 0) return null;
const count = properties.length;
if (count === 0) {
return (
<div className="rounded-2xl border border-gray-100 bg-white px-6 py-16 text-center shadow-sm">
<CheckCircleIcon className="mx-auto h-9 w-9 text-emerald-500" />
<p className="mt-3 text-sm font-semibold text-gray-700">
All properties are matched
</p>
<p className="mt-1 text-xs text-gray-400">
Every property has a UPRN nothing needs attention.
</p>
</div>
);
}
return (
<section
aria-label="Properties without a UPRN"
className="mb-6 overflow-hidden rounded-2xl border border-amber-200 bg-amber-50 shadow-sm"
className="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" />
@ -35,8 +49,9 @@ export default function UnmatchedProperties({
</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.
matched to an Ordnance Survey address automatically. Correct the
address and postcode so {count === 1 ? "it's" : "they're"} ready to
match they stay out of the main list until resolved.
</p>
</div>
</div>

View file

@ -739,6 +739,9 @@ export async function getPropertiesCount(
${epcJoins}
${planJoin}
WHERE p.portfolio_id = ${portfolioId}
-- Unmatched (no-UPRN) properties live in the "Needs attention" tab until
-- resolved, so they're excluded from the main table + its count.
AND p.uprn IS NOT NULL
${combinedWhere}
`);
@ -831,6 +834,9 @@ export async function getProperties(
ON epc.property_id = p.id
${newApproachJoins}
WHERE p.portfolio_id = ${portfolioId}
-- Unmatched (no-UPRN) properties live in the "Needs attention" tab until
-- resolved, so they're excluded from the main table.
AND p.uprn IS NOT NULL
${combinedWhere}
LIMIT ${limit} OFFSET ${offset};
`);