From 7ac632a7aa3445920160d1b4d04e32d3ba888df1 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 7 Jul 2026 11:17:48 +0000 Subject: [PATCH] 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) --- src/app/portfolio/[slug]/(portfolio)/page.tsx | 12 ++- .../[slug]/components/PortfolioTabs.tsx | 74 +++++++++++++++++++ .../[slug]/components/UnmatchedProperties.tsx | 35 ++++++--- src/app/portfolio/[slug]/utils.ts | 6 ++ 4 files changed, 113 insertions(+), 14 deletions(-) create mode 100644 src/app/portfolio/[slug]/components/PortfolioTabs.tsx diff --git a/src/app/portfolio/[slug]/(portfolio)/page.tsx b/src/app/portfolio/[slug]/(portfolio)/page.tsx index 9af81277..24bd0aba 100644 --- a/src/app/portfolio/[slug]/(portfolio)/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/page.tsx @@ -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 ( - <> - - - + } + needsAttention={ + + } + /> ); } diff --git a/src/app/portfolio/[slug]/components/PortfolioTabs.tsx b/src/app/portfolio/[slug]/components/PortfolioTabs.tsx new file mode 100644 index 00000000..59aba28d --- /dev/null +++ b/src/app/portfolio/[slug]/components/PortfolioTabs.tsx @@ -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 ( +
+
+ setTab("properties")} + > + Properties + + setTab("needs-attention")} + > + + Needs attention + {needsAttentionCount > 0 && ( + + {needsAttentionCount} + + )} + +
+ +
{properties}
+
+ {needsAttention} +
+
+ ); +} + +function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx index 60a1dcdf..adaf4e15 100644 --- a/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx +++ b/src/app/portfolio/[slug]/components/UnmatchedProperties.tsx @@ -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 ( +
+ +

+ All properties are matched +

+

+ Every property has a UPRN — nothing needs attention. +

+
+ ); + } + return (
@@ -35,8 +49,9 @@ export default function UnmatchedProperties({

{count === 1 ? "This property" : "These properties"} couldn'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.

diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index 6014cc25..0eb1b88e 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -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}; `);