From 8a0ad0c0dc01210d9a882bc26f296a95aa230e39 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 14:33:22 +0000 Subject: [PATCH] feat(postcode-search): routes, add-properties page, PropertyTable entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the add-properties-by-postcode journey (ADR-0007) on top of the TDD'd domain core: - GET /api/portfolio/[id]/properties/search?postcode= — postcodes.io for validation + geography (admin_district, parliamentary_constituency) every time; OS Places through the 30-day postcode_search read-through cache (refresh=1 bypasses it). Returns candidates with alreadyInPortfolio flags from one grouped uprn query. Cache reads accept legacy space-less keys; writes use the canonical spaced form. - POST /api/portfolio/[id]/properties — re-derives facts server-side from classification codes (client propertyType never trusted), then one transaction: property rows via uq_property_portfolio_uprn ON CONFLICT DO NOTHING, vocabulary upserts with os_places provenance (DO NOTHING preserves user rows; snapshots mirror the resolved value), property_overrides snapshots (building_part 0, original description = OS code), and property_details_spatial per-property coordinates. Returns {added, skipped}. - OS Places fetches now request output_srs=EPSG:4326 so records carry LAT/LNG (no mapper read the BNG X/Y coordinates). - /portfolio/[slug]/properties/add built to the approved mockup: search card with cache-age line, per-postcode result cards, cross-postcode selection tray, done state. TanStack Query v4, no useEffect/useMemo. - "Add properties" dropdown beside Export in PropertyTable (search by postcode + bulk excel import "Soon"); toolbar AddNew dropdown retired. - New display/cache helpers (describeCandidate, postcodeCacheKeys, cacheAgeDays) TDD'd in the domain core. Verified live against portfolio 814: search (cache + refresh), submit (added 2, all four write layers checked in DB), dedup re-submit (skipped 2), alreadyInPortfolio flags, page + dropdown render. Co-Authored-By: Claude Fable 5 --- .../[portfolioId]/properties/route.ts | 197 +++++++++ .../[portfolioId]/properties/search/route.ts | 126 ++++++ src/app/api/postcode/LookupOsPlaces.ts | 5 +- src/app/api/postcode/LookupPostcode.ts | 1 + src/app/components/portfolio/AddNew.tsx | 158 ------- src/app/components/portfolio/Toolbar.tsx | 19 +- .../portfolio/[slug]/(portfolio)/layout.tsx | 6 +- .../[slug]/components/PropertyTable.tsx | 53 ++- .../properties/add/AddPropertiesClient.tsx | 401 ++++++++++++++++++ .../portfolio/[slug]/properties/add/page.tsx | 11 + src/lib/postcodeSearch/model.test.ts | 42 ++ src/lib/postcodeSearch/model.ts | 39 ++ 12 files changed, 876 insertions(+), 182 deletions(-) create mode 100644 src/app/api/portfolio/[portfolioId]/properties/route.ts create mode 100644 src/app/api/portfolio/[portfolioId]/properties/search/route.ts delete mode 100644 src/app/components/portfolio/AddNew.tsx create mode 100644 src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx create mode 100644 src/app/portfolio/[slug]/properties/add/page.tsx diff --git a/src/app/api/portfolio/[portfolioId]/properties/route.ts b/src/app/api/portfolio/[portfolioId]/properties/route.ts new file mode 100644 index 00000000..0e35c1e6 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/properties/route.ts @@ -0,0 +1,197 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { z } from "zod"; +import { db } from "@/app/db/db"; +import { property, propertyDetailsSpatial } from "@/app/db/schema/property"; +import { + landlordBuiltFormTypeOverrides, + landlordPropertyTypeOverrides, +} from "@/app/db/schema/landlord_overrides"; +import { propertyOverrides } from "@/app/db/schema/property_overrides"; +import { and, eq, inArray } from "drizzle-orm"; +import { + buildWritePlan, + classifyOsCode, + normalisePostcode, +} from "@/lib/postcodeSearch/model"; + +const bodySchema = z.object({ + geo: z.object({ + localAuthority: z.string().nullable(), + constituency: z.string().nullable(), + }), + candidates: z + .array( + z.object({ + uprn: z.string().regex(/^\d+$/), + address: z.string().trim().min(1), + postcode: z.string(), + classificationCode: z.string().nullable(), + lat: z.number().nullable(), + lng: z.number().nullable(), + }), + ) + .min(1) + .max(500), +}); + +// POST — create the selected address candidates as Properties (ADR-0007): +// property rows via the uq_property_portfolio_uprn dedup, plus both override +// layers (vocabulary upsert + per-property snapshot) with os_places provenance. +// Facts are re-derived server-side from the classification code — the client's +// propertyType/builtForm are never trusted. +export async function POST( + request: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + const { portfolioId } = await props.params; + if (!/^\d+$/.test(portfolioId)) { + return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 }); + } + const pid = BigInt(portfolioId); + + let body: z.infer; + try { + body = bodySchema.parse(await request.json()); + } catch { + return NextResponse.json({ error: "Invalid body" }, { status: 400 }); + } + + const seen = new Set(); + const plans: ReturnType[] = []; + for (const c of body.candidates) { + const postcode = normalisePostcode(c.postcode); + if (!postcode) { + return NextResponse.json( + { error: `Invalid postcode '${c.postcode}'` }, + { status: 400 }, + ); + } + const classificationCode = c.classificationCode?.trim().toUpperCase() ?? null; + const cls = classifyOsCode(classificationCode); + if (!cls.selectable || seen.has(c.uprn)) continue; + seen.add(c.uprn); + plans.push( + buildWritePlan( + { ...c, postcode, classificationCode, ...cls }, + body.geo, + ), + ); + } + if (plans.length === 0) { + return NextResponse.json( + { error: "No addable addresses in the selection" }, + { status: 400 }, + ); + } + + try { + const result = await db.transaction(async (tx) => { + // Vocabulary layer: one upsert per distinct OS code, then read back the + // resolved value so snapshots mirror it — DO NOTHING preserves any + // existing user/classifier row for the same description (existing rule). + const factsFor = (component: string) => { + const byDescription = new Map(); + for (const p of plans) + for (const o of p.overrides) + if (o.component === component) + byDescription.set(o.originalDescription, o.value); + return byDescription; + }; + const resolved = new Map(); + for (const [component, table] of [ + ["property_type", landlordPropertyTypeOverrides], + ["built_form_type", landlordBuiltFormTypeOverrides], + ] as const) { + const facts = factsFor(component); + if (facts.size === 0) continue; + await tx + .insert(table) + .values( + [...facts].map(([description, value]) => ({ + portfolioId: pid, + description, + value, + source: "os_places", + })), + ) + .onConflictDoNothing(); + const rows = await tx + .select({ description: table.description, value: table.value }) + .from(table) + .where( + and( + eq(table.portfolioId, pid), + inArray(table.description, [...facts.keys()]), + ), + ); + for (const r of rows) resolved.set(`${component}|${r.description}`, r.value); + } + + const inserted = await tx + .insert(property) + .values( + plans.map((p) => ({ + portfolioId: pid, + creationStatus: "READY", + uprn: p.property.uprn, + address: p.property.address, + postcode: p.property.postcode, + localAuthority: p.property.localAuthority, + constituency: p.property.constituency, + })), + ) + .onConflictDoNothing() + .returning({ id: property.id, uprn: property.uprn }); + const idByUprn = new Map( + inserted.map((r) => [r.uprn!.toString(), r.id]), + ); + + const spatialRows = plans + .filter( + (p) => + idByUprn.has(p.property.uprn.toString()) && + p.property.latitude != null && + p.property.longitude != null, + ) + .map((p) => ({ + uprn: p.property.uprn, + latitude: p.property.latitude, + longitude: p.property.longitude, + })); + if (spatialRows.length > 0) { + await tx.insert(propertyDetailsSpatial).values(spatialRows).onConflictDoNothing(); + } + + const snapshotRows = plans.flatMap((p) => { + const propertyId = idByUprn.get(p.property.uprn.toString()); + if (!propertyId) return []; + return p.overrides.map((o) => ({ + propertyId, + portfolioId: pid, + buildingPart: o.buildingPart, + overrideComponent: o.component, + overrideValue: + resolved.get(`${o.component}|${o.originalDescription}`) ?? o.value, + originalSpreadsheetDescription: o.originalDescription, + })); + }); + if (snapshotRows.length > 0) { + await tx.insert(propertyOverrides).values(snapshotRows).onConflictDoNothing(); + } + + return { added: inserted.length, skipped: plans.length - inserted.length }; + }); + + return NextResponse.json(result); + } catch (err) { + console.error("POST /properties error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/portfolio/[portfolioId]/properties/search/route.ts b/src/app/api/portfolio/[portfolioId]/properties/search/route.ts new file mode 100644 index 00000000..97671b4a --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/properties/search/route.ts @@ -0,0 +1,126 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; +import { db } from "@/app/db/db"; +import { postcodeSearch, OSPlacesItem } from "@/app/db/schema/addresses"; +import { property } from "@/app/db/schema/property"; +import { and, desc, eq, inArray } from "drizzle-orm"; +import { lookupPostcode } from "@/app/api/postcode/LookupPostcode"; +import { lookupOsPlaces } from "@/app/api/postcode/LookupOsPlaces"; +import { + cacheAgeDays, + isCacheFresh, + normalisePostcode, + postcodeCacheKeys, + toAddressCandidates, +} from "@/lib/postcodeSearch/model"; + +// GET ?postcode= — address candidates for the add-properties journey +// (ADR-0007): postcodes.io for validation + geography every time, OS Places +// through the 30-day postcode_search read-through cache. +export async function GET( + request: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + const { portfolioId } = await props.params; + if (!/^\d+$/.test(portfolioId)) { + return NextResponse.json({ error: "Invalid portfolio id" }, { status: 400 }); + } + + const postcode = normalisePostcode( + request.nextUrl.searchParams.get("postcode") ?? "", + ); + if (!postcode) { + return NextResponse.json( + { error: "That doesn't look like a full UK postcode" }, + { status: 400 }, + ); + } + + try { + const geoLookup = await lookupPostcode(postcode); + if (!geoLookup.result) { + const notFound = geoLookup.status === 404; + return NextResponse.json( + { error: notFound ? "Postcode not found" : (geoLookup.error ?? "Postcode lookup failed") }, + { status: notFound ? 404 : 502 }, + ); + } + const geo = { + localAuthority: geoLookup.result.admin_district ?? null, + constituency: geoLookup.result.parliamentary_constituency ?? null, + }; + + const now = new Date(); + // refresh=1 bypasses the cache ("Refresh from source") — the upsert below + // still rewrites the row, so the next reader gets the fresh copy. + const refresh = request.nextUrl.searchParams.get("refresh") === "1"; + const cached = await db + .select() + .from(postcodeSearch) + .where(inArray(postcodeSearch.postcode, postcodeCacheKeys(postcode))) + .orderBy(desc(postcodeSearch.lastUpdatedAt)) + .limit(1); + + let items: OSPlacesItem[]; + let source: "cache" | "live"; + let fetchedAt: Date; + if (!refresh && cached.length > 0 && isCacheFresh(cached[0].lastUpdatedAt, now)) { + items = cached[0].resultData?.results ?? []; + source = "cache"; + fetchedAt = cached[0].lastUpdatedAt; + } else { + const os = await lookupOsPlaces(postcode); + if (os.error || !os.data) { + return NextResponse.json( + { error: os.error ?? "Failed to fetch address data" }, + { status: os.status >= 400 ? os.status : 502 }, + ); + } + items = os.results ?? []; + source = "live"; + fetchedAt = now; + await db + .insert(postcodeSearch) + .values({ postcode, resultData: os.data, lastUpdatedAt: now }) + .onConflictDoUpdate({ + target: postcodeSearch.postcode, + set: { resultData: os.data, lastUpdatedAt: now }, + }); + } + + const candidates = toAddressCandidates(items, postcode); + const uprns = candidates.map((c) => BigInt(c.uprn)); + const existing = uprns.length + ? await db + .select({ uprn: property.uprn }) + .from(property) + .where( + and( + eq(property.portfolioId, BigInt(portfolioId)), + inArray(property.uprn, uprns), + ), + ) + : []; + const inPortfolio = new Set(existing.map((r) => r.uprn?.toString())); + + return NextResponse.json({ + postcode, + geo, + source, + cacheAgeDays: cacheAgeDays(fetchedAt, now), + candidates: candidates.map((c) => ({ + ...c, + alreadyInPortfolio: inPortfolio.has(c.uprn), + })), + }); + } catch (err) { + console.error("GET /properties/search error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/api/postcode/LookupOsPlaces.ts b/src/app/api/postcode/LookupOsPlaces.ts index 720e984f..1998ccfa 100644 --- a/src/app/api/postcode/LookupOsPlaces.ts +++ b/src/app/api/postcode/LookupOsPlaces.ts @@ -24,9 +24,12 @@ async function fetchOsPlacesPage( retries: number, delay: number ): Promise<{ status: number; data?: OSPlacesResponse; error?: string }> { + // output_srs=EPSG:4326 makes each DPA/LPI record carry LAT/LNG (WGS84) + // instead of BNG eastings/northings — the postcode-search journey stores + // per-property coordinates. No mapper reads X/Y_COORDINATE. const url = `https://api.os.uk/search/places/v1/postcode?postcode=${encodeURIComponent( postcode - )}&dataset=DPA,LPI&maxresults=100&offset=${offset}&key=${process.env.OS_API_KEY}`; + )}&dataset=DPA,LPI&maxresults=100&offset=${offset}&output_srs=EPSG%3A4326&key=${process.env.OS_API_KEY}`; try { const res = await fetch(url, { method: "GET" }); diff --git a/src/app/api/postcode/LookupPostcode.ts b/src/app/api/postcode/LookupPostcode.ts index c9376f42..9e240a1e 100644 --- a/src/app/api/postcode/LookupPostcode.ts +++ b/src/app/api/postcode/LookupPostcode.ts @@ -7,6 +7,7 @@ export interface PostcodeLookupResult { country: string; region: string | null; admin_district: string | null; + parliamentary_constituency: string | null; latitude: number; longitude: number; }; diff --git a/src/app/components/portfolio/AddNew.tsx b/src/app/components/portfolio/AddNew.tsx deleted file mode 100644 index cef42cb9..00000000 --- a/src/app/components/portfolio/AddNew.tsx +++ /dev/null @@ -1,158 +0,0 @@ -"use client"; - -import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/react"; - -import { - TableCellsIcon, - DocumentMagnifyingGlassIcon, - ChevronDownIcon, - DocumentPlusIcon, - RectangleStackIcon, -} from "@heroicons/react/24/outline"; - -import { cn } from "@/lib/utils"; -import { useRouter } from "next/navigation"; -import { Dispatch, SetStateAction, useState } from "react"; -import BulkUploadComingSoonModal from "@/app/components/portfolio/BulkUploadComingSoonModal"; - -interface AddNewProps { - portfolioId: string; - isUploadCsvOpen: boolean; - setIsUploadCsvOpen: Dispatch>; -} - -export default function AddNew({ - portfolioId, - isUploadCsvOpen, - setIsUploadCsvOpen, -}: AddNewProps) { - const router = useRouter(); - const [loadingRemote, setLoadingRemote] = useState(false); - const [isBulkUploadOpen, setIsBulkUploadOpen] = useState(false); - - function handleRemoteAssessment() { - setLoadingRemote(true); - router.push(`/portfolio/${portfolioId}/remote-assessment`); - } - - function handleBulkUploadClick() { - const pw = window.prompt("Enter password to access bulk upload"); - if (pw === null) return; - if (pw === "domnatechteamonly") { - setIsBulkUploadOpen(true); - } else { - window.alert("Incorrect password"); - } - } - - return ( - <> - setIsBulkUploadOpen(false)} - portfolioId={portfolioId} - /> - - - - New Property - - - - -
- {/* Remote Assessment */} - - {({ active }) => ( - - )} - - - {/* CSV Upload */} - - {({ active }) => ( - - )} - - - {/* Bulk Upload (Coming Soon) */} - - {({ active }) => ( - - )} - -
-
-
- - ); -} diff --git a/src/app/components/portfolio/Toolbar.tsx b/src/app/components/portfolio/Toolbar.tsx index 5f50f030..05c2bfa8 100644 --- a/src/app/components/portfolio/Toolbar.tsx +++ b/src/app/components/portfolio/Toolbar.tsx @@ -13,23 +13,18 @@ import { NavigationMenuList, NavigationMenuViewport, } from "@/app/shadcn_components/ui/navigation-menu"; -import AddNewDropDown from "./AddNew"; import YourProjectsDropdown from "./YourProjectsDropdown"; -import UploadCsvModal from "@/app/portfolio/[slug]/components/UploadCsvModal"; -import { ScenarioSelect } from "@/app/db/schema/recommendations"; import { useState, useTransition } from "react"; import { useRouter, usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; interface ToolbarProps { portfolioId: string; - scenarios: ScenarioSelect[]; } -export function Toolbar({ portfolioId, scenarios }: ToolbarProps) { +export function Toolbar({ portfolioId }: ToolbarProps) { const router = useRouter(); const pathname = usePathname(); - const [modalIsOpen, setModalIsOpen] = useState(false); const [loadingHref, setLoadingHref] = useState(null); const [isPending, startTransition] = useTransition(); @@ -110,11 +105,6 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) { ); })} - {SettingsItems.map(({ label, icon: Icon, href, match }) => { const isActive = match(pathname); const isLoading = loadingHref === href && isPending; @@ -152,13 +142,6 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) { - - ); } diff --git a/src/app/portfolio/[slug]/(portfolio)/layout.tsx b/src/app/portfolio/[slug]/(portfolio)/layout.tsx index ac7be23c..bf5c76d9 100644 --- a/src/app/portfolio/[slug]/(portfolio)/layout.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/layout.tsx @@ -1,5 +1,5 @@ import { Toolbar } from "@/app/components/portfolio/Toolbar"; -import { getPortfolio, getPortfolioScenarios } from "../utils"; +import { getPortfolio } from "../utils"; import * as React from "react"; @@ -16,8 +16,6 @@ export default async function PortfolioLayout(props: { const portfolioId = params.slug; const { name: portfolioName } = await getPortfolio(portfolioId); - // We retrieve the scenarios associated with the portfolio - const scenarios = await getPortfolioScenarios(portfolioId); return (
@@ -29,7 +27,7 @@ export default async function PortfolioLayout(props: {
- +
{children} diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index e12550ea..8fd7aed1 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -11,7 +11,11 @@ import { XMarkIcon, ArrowDownTrayIcon, ViewColumnsIcon, + PlusIcon, + MagnifyingGlassIcon, + DocumentArrowUpIcon, } from "@heroicons/react/24/outline"; +import { useRouter } from "next/navigation"; import { HomeIcon } from "@heroicons/react/24/outline"; import { sapToEpc } from "@/app/utils"; import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns"; @@ -146,7 +150,7 @@ function EmptyPropertyState() {

- Hover over “New Property” to start adding + Use “Add properties” to start adding properties to your portfolio.

@@ -301,6 +305,7 @@ export default function PropertyTable({ }: { portfolioId: string; }) { + const router = useRouter(); const [sidebarOpen, setSidebarOpen] = useState(false); const [committedAddress, setCommittedAddress] = useState(""); @@ -621,6 +626,52 @@ export default function PropertyTable({ Export )} + + {/* Add properties */} + + + + + + + router.push(`/portfolio/${portfolioId}/properties/add`) + } + > + + + + Search by postcode + + + Find addresses and add them in a few clicks + + + + + + + + Bulk excel import + + + Upload a spreadsheet of addresses + + + + Soon + + + +
diff --git a/src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx b/src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx new file mode 100644 index 00000000..34cfe027 --- /dev/null +++ b/src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx @@ -0,0 +1,401 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CheckIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import { + AddressCandidate, + PostcodeGeo, + describeCandidate, + normalisePostcode, +} from "@/lib/postcodeSearch/model"; + +type SearchCandidate = AddressCandidate & { alreadyInPortfolio: boolean }; + +interface SearchResponse { + postcode: string; + geo: PostcodeGeo; + source: "cache" | "live"; + cacheAgeDays: number; + candidates: SearchCandidate[]; +} + +// Selections survive across searches so a user can gather several postcodes +// before submitting once; each group keeps its own geography for the POST. +type Basket = Record< + string, + { geo: PostcodeGeo; byUprn: Record } +>; + +const countSelected = (basket: Basket) => + Object.values(basket).reduce((n, g) => n + Object.keys(g.byUprn).length, 0); + +export default function AddPropertiesClient({ + portfolioId, + portfolioName, +}: { + portfolioId: string; + portfolioName: string; +}) { + const router = useRouter(); + const queryClient = useQueryClient(); + const [input, setInput] = useState(""); + const [inputError, setInputError] = useState(null); + const [search, setSearch] = useState<{ pc: string; refresh: number } | null>(null); + const [basket, setBasket] = useState({}); + const [done, setDone] = useState<{ added: number; skipped: number } | null>(null); + + const query = useQuery({ + queryKey: ["postcode-search", portfolioId, search?.pc, search?.refresh], + enabled: !!search, + staleTime: 5 * 60 * 1000, + retry: false, + queryFn: async () => { + const params = new URLSearchParams({ postcode: search!.pc }); + if (search!.refresh > 0) params.set("refresh", "1"); + const res = await fetch( + `/api/portfolio/${portfolioId}/properties/search?${params}`, + ); + const body = await res.json(); + if (!res.ok) throw new Error(body.error ?? "Search failed"); + return body as SearchResponse; + }, + }); + + const submit = useMutation< + { added: number; skipped: number }, + Error, + Basket + >({ + mutationFn: async (toSubmit) => { + let added = 0; + let skipped = 0; + for (const [, group] of Object.entries(toSubmit)) { + const res = await fetch(`/api/portfolio/${portfolioId}/properties`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + geo: group.geo, + candidates: Object.values(group.byUprn), + }), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error ?? "Adding properties failed"); + added += body.added; + skipped += body.skipped; + } + return { added, skipped }; + }, + onSuccess: (totals) => { + setBasket({}); + setDone(totals); + // Added properties change the search's alreadyInPortfolio flags + queryClient.invalidateQueries(["postcode-search", portfolioId]); + }, + }); + + const doSearch = () => { + const pc = normalisePostcode(input); + if (!pc) { + setInputError("That doesn't look like a full UK postcode"); + return; + } + setInputError(null); + setDone(null); + setSearch({ pc, refresh: 0 }); + }; + + const toggle = (postcode: string, geo: PostcodeGeo, c: SearchCandidate) => { + setBasket((prev) => { + const group = prev[postcode] ?? { geo, byUprn: {} }; + const byUprn = { ...group.byUprn }; + if (byUprn[c.uprn]) delete byUprn[c.uprn]; + else byUprn[c.uprn] = c; + const next = { ...prev, [postcode]: { geo, byUprn } }; + if (Object.keys(byUprn).length === 0) delete next[postcode]; + return next; + }); + }; + + const data = done ? undefined : query.data; + const selected = data ? (basket[data.postcode]?.byUprn ?? {}) : {}; + const addable = data + ? data.candidates.filter((c) => c.selectable && !c.alreadyInPortfolio) + : []; + const totalSelected = countSelected(basket); + const postcodes = Object.keys(basket); + + return ( +
+ {/* Breadcrumb */} +
+ + {portfolioName} / Portfolio + {" "} + / Add properties +
+ +

+ Bring your properties in. +

+

+ Search a postcode and pick the homes that belong to this portfolio. We + identify each address against the national register — type and built + form come with it, ready for modelling. +

+ + {/* Search card */} +
+ + Search by postcode + +
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") doSearch(); + }} + placeholder='e.g. "M20 4TF"' + autoFocus + className="flex-1 border border-slate-200 rounded-xl px-4 py-2.5 text-base font-semibold uppercase placeholder:normal-case placeholder:font-normal placeholder:text-sm placeholder:text-slate-400 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/10" + /> + +
+
+ {inputError ? ( + {inputError} + ) : query.isError ? ( + {query.error.message} + ) : data ? ( + data.source === "cache" ? ( + <> + Results from our cache,{" "} + + {data.cacheAgeDays === 0 + ? "refreshed today" + : `${data.cacheAgeDays} day${data.cacheAgeDays === 1 ? "" : "s"} old`} + {" "} + ·{" "} + + + ) : ( + <>Fresh from the national register just now + ) + ) : ( + <>We look every postcode up once and remember it — repeat searches are instant. + )} +
+
+ + {/* Done state */} + {done && ( +
+
+ +
+

+ {done.added} {done.added === 1 ? "property" : "properties"} added +

+

+ They're in the portfolio now, marked{" "} + + + Awaiting modelling + {" "} + — addresses are real from day one; energy data arrives when you run + a scenario against them. +

+ {done.skipped > 0 && ( +

+ {done.skipped} {done.skipped === 1 ? "was" : "were"} already in + the portfolio and left untouched. +

+ )} +
+ + +
+
+ )} + + {/* Results */} + {!done && !data && !query.isFetching && ( +
+ Searched addresses appear here — tick the ones that belong to this + portfolio. +
+ )} + + {!done && data && ( +
+
+

{data.postcode}

+ + {data.candidates.length}{" "} + {data.candidates.length === 1 ? "address" : "addresses"} + + {addable.length > 0 && ( + <> + + + + )} + + {Object.keys(selected).length} selected + +
+ + {data.candidates.map((c) => { + const { label, note } = describeCandidate(c); + const disabled = !c.selectable || c.alreadyInPortfolio; + const isSelected = !!selected[c.uprn]; + return ( + + ); + })} +
+ )} + + {/* Selection tray */} + {totalSelected > 0 && !done && ( +
+ {postcodes.map((pc) => ( + + {pc} · {Object.keys(basket[pc].byUprn).length} + + + ))} + + {totalSelected}{" "} + {totalSelected === 1 ? "property" : "properties"} across{" "} + {postcodes.length} {postcodes.length === 1 ? "postcode" : "postcodes"} + + {submit.isError && ( + {submit.error.message} + )} + +
+ )} +
+ ); +} diff --git a/src/app/portfolio/[slug]/properties/add/page.tsx b/src/app/portfolio/[slug]/properties/add/page.tsx new file mode 100644 index 00000000..9d4139d7 --- /dev/null +++ b/src/app/portfolio/[slug]/properties/add/page.tsx @@ -0,0 +1,11 @@ +import { getPortfolio } from "../../utils"; +import AddPropertiesClient from "./AddPropertiesClient"; + +export default async function Page(props: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await props.params; + const { name: portfolioName } = await getPortfolio(slug); + + return ; +} diff --git a/src/lib/postcodeSearch/model.test.ts b/src/lib/postcodeSearch/model.test.ts index 335d7d96..5268f10d 100644 --- a/src/lib/postcodeSearch/model.test.ts +++ b/src/lib/postcodeSearch/model.test.ts @@ -1,9 +1,12 @@ import { describe, expect, it } from "vitest"; import { buildWritePlan, + cacheAgeDays, classifyOsCode, + describeCandidate, isCacheFresh, normalisePostcode, + postcodeCacheKeys, toAddressCandidates, } from "./model"; @@ -91,6 +94,45 @@ describe("buildWritePlan", () => { }); }); +describe("describeCandidate", () => { + const base = { selectable: true, propertyType: null, builtForm: null, classificationCode: null }; + it("labels fully-mapped candidates with both facts", () => { + expect(describeCandidate({ ...base, classificationCode: "RD02", propertyType: "House", builtForm: "Detached" })) + .toEqual({ label: "House · Detached", note: null }); + }); + it("explains why terraced houses carry no built form", () => { + expect(describeCandidate({ ...base, classificationCode: "RD04", propertyType: "House" })) + .toEqual({ label: "House", note: "Terraced — mid or end unknown" }); + // Other single-fact codes carry no note + expect(describeCandidate({ ...base, classificationCode: "RD07", propertyType: "House" })) + .toEqual({ label: "House", note: null }); + expect(describeCandidate({ ...base, classificationCode: "RD06", propertyType: "Flat" })) + .toEqual({ label: "Flat", note: null }); + }); + it("labels selectable-but-unmapped codes Residential, with the modelling note", () => { + expect(describeCandidate({ ...base, classificationCode: "RD" })) + .toEqual({ label: "Residential", note: "No classification — type comes with modelling" }); + }); + it("labels unselectable candidates Non-residential", () => { + expect(describeCandidate({ ...base, selectable: false, classificationCode: "CR08" })) + .toEqual({ label: "Non-residential", note: null }); + }); +}); + +describe("postcodeCacheKeys", () => { + it("reads the canonical spaced form first, then the legacy space-less form", () => { + expect(postcodeCacheKeys("M20 4TF")).toEqual(["M20 4TF", "M204TF"]); + }); +}); + +describe("cacheAgeDays", () => { + const now = new Date("2026-07-06T12:00:00Z"); + it("floors to whole days", () => { + expect(cacheAgeDays(new Date("2026-07-03T11:00:00Z"), now)).toBe(3); + expect(cacheAgeDays(new Date("2026-07-06T09:00:00Z"), now)).toBe(0); + }); +}); + describe("normalisePostcode", () => { it("uppercases and collapses to the canonical single-space form", () => { expect(normalisePostcode("m20 4tf")).toBe("M20 4TF"); diff --git a/src/lib/postcodeSearch/model.ts b/src/lib/postcodeSearch/model.ts index 364b2796..92bb9d09 100644 --- a/src/lib/postcodeSearch/model.ts +++ b/src/lib/postcodeSearch/model.ts @@ -136,6 +136,45 @@ export function buildWritePlan(c: AddressCandidate, geo: PostcodeGeo) { }; } +/** + * Presentation of a candidate's classification: the type chip label plus an + * optional explanatory note (why terraced has no built form; why an unmapped + * residential code still adds cleanly). + */ +export function describeCandidate(c: { + selectable: boolean; + propertyType?: string | null; + builtForm?: string | null; + classificationCode?: string | null; +}): { label: string; note: string | null } { + if (!c.selectable) return { label: "Non-residential", note: null }; + if (c.propertyType && c.builtForm) { + return { label: `${c.propertyType} · ${c.builtForm}`, note: null }; + } + if (c.propertyType) { + return { + label: c.propertyType, + note: c.classificationCode?.toUpperCase() === "RD04" + ? "Terraced — mid or end unknown" + : null, + }; + } + return { label: "Residential", note: "No classification — type comes with modelling" }; +} + +/** + * postcode_search cache keys to read, canonical spaced form first. Legacy rows + * were keyed without the space; new rows are written under the canonical form. + */ +export function postcodeCacheKeys(postcode: string): string[] { + return [postcode, postcode.replace(" ", "")]; +} + +/** Whole days since the cache row was refreshed (for the "N days old" line). */ +export function cacheAgeDays(fetchedAt: Date, now: Date): number { + return Math.max(0, Math.floor((now.getTime() - fetchedAt.getTime()) / (24 * 60 * 60 * 1000))); +} + export function classifyOsCode(code: string | null): OsClassification { if (!code) return { selectable: false, propertyType: null, builtForm: null }; const c = code.trim().toUpperCase();