mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-12 13:28:55 +00:00
Merge pull request #355 from Hestia-Homes/feature/add-properties-by-postcode
feat: add properties by postcode search
This commit is contained in:
commit
08fcb77734
15 changed files with 1235 additions and 184 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -40,7 +40,9 @@ next-env.d.ts
|
|||
|
||||
backlog/**
|
||||
|
||||
docs/adr/**
|
||||
# Session working notes / handovers — not for review (PR #355 feedback)
|
||||
docs/wip/**
|
||||
|
||||
|
||||
# Personal Claude Code settings (per-developer, not shared)
|
||||
.claude/settings.local.json
|
||||
|
|
|
|||
10
CONTEXT.md
10
CONTEXT.md
|
|
@ -45,14 +45,22 @@ _Avoid_: customer data, manual override, landlord data
|
|||
The per-Property fact layer — one resolved fact per `(Property, Building part, component)`, where component is one of `wall_type`/`roof_type`/`property_type`/`built_form_type`. Holds a **snapshot** of the resolved enum value (a denormalised copy of the VocabularyMapping outcome at finalise time, so two Properties sharing a description can later diverge), plus the original spreadsheet text it resolved from. Materialised by the finaliser **for UPRN-matched Properties only** (v2); the resolved value is never `UNKNOWN` — the Verify step forces every `UNKNOWN` to be mapped before Finalise, and an unresolved description fails the run. See [ADR-0005](./docs/adr/0005-async-bulk-upload-finaliser.md) (table) and [ADR-0006](./docs/adr/0006-property-overrides-join-and-no-uprn-defer.md) (population).
|
||||
_Avoid_: per-property mapping, property fact, override row
|
||||
|
||||
The Model backend consumes Property overrides at modelling time; property type (and built form when known) lets the **predict-EPC service** estimate a never-EPC'd property from surrounding homes of similar archetype.
|
||||
|
||||
**Source row id**:
|
||||
A synthetic UUID minted per source-file row at `start-address-matching` and written into **both** the address CSV and the classifier CSV. It is the stable join key that lets the finaliser tie a row's identity (combiner output → `property_id`) to that row's raw descriptions (classifier CSV), since neither file preserves row order and `Internal Reference` is absent from the classifier CSV. See [ADR-0006](./docs/adr/0006-property-overrides-join-and-no-uprn-defer.md).
|
||||
_Avoid_: row index, internal reference (a separate, optional landlord field)
|
||||
|
||||
**VocabularyMapping**:
|
||||
The translation from a Landlord's free-text description in a BulkUpload column (e.g. `"cavity: filledcavity"`) to a canonical domain enum value (e.g. `WallType.CAVITY`). Produced by a `ColumnClassifier` (today an LLM, tomorrow possibly a lookup table or rules engine) in the Model service. Stored per-Portfolio, one row per `(category, description)`. A row carries provenance (`classifier` or `user`) so user overrides survive re-classification.
|
||||
The translation from a free-text description to a canonical domain enum value (e.g. `"cavity: filledcavity"` → `WallType.CAVITY`). Two producers: the `ColumnClassifier` (an LLM in the Model service — needed because BulkUpload spreadsheets contain arbitrary landlord text that must be sanitised) and the **deterministic OS-code mapping** used by the postcode-search journey (a fixed OS classification code → enum table; no interpretation involved). Stored per-Portfolio, one row per `(category, description)`. A row carries provenance (`classifier`, `user`, or `os_places`) so user overrides survive re-classification and OS-derived facts stay distinguishable.
|
||||
_Avoid_: column mapping (that's a separate concept — see `ColumnMapping` above), classification, dictionary
|
||||
|
||||
### Postcode search
|
||||
|
||||
**Postcode search**:
|
||||
The journey that adds Properties to a Portfolio by searching a postcode: postcodes.io validates/normalises and supplies geography (local authority, constituency), OS Places supplies the addresses (UPRN, coordinates, classification code), and the user selects which addresses to add — across multiple postcode searches in one basket, submitted once. Raw OS responses are cached per-postcode (`postcode_search`) and re-served while fresh. Creates real Properties whose energy data is absent-until-modelled (derived state, no marker column); writes property type / built form facts through the Landlord-override layers via the deterministic OS-code mapping.
|
||||
_Avoid_: address import, quick add, single add
|
||||
|
||||
### Building parts
|
||||
|
||||
**Building part**:
|
||||
|
|
|
|||
60
docs/adr/0007-postcode-search-creates-properties.md
Normal file
60
docs/adr/0007-postcode-search-creates-properties.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# 7. Postcode search creates Properties directly, with OS-derived overrides
|
||||
|
||||
Date: 2026-07-06
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
Numbering note: 0003 is taken by the app-authored-scenarios ADR (PR #353);
|
||||
0004–0006 are referenced by CONTEXT.md but were lost to a `docs/adr/**`
|
||||
.gitignore rule (removed in this commit). 0007 avoids all collisions.
|
||||
|
||||
## Context
|
||||
|
||||
Properties previously came into existence only via the BulkUpload finaliser or
|
||||
the backend pipeline. The "Postcode search" journey (CONTEXT.md) lets a user
|
||||
search a postcode (postcodes.io for validation + geography, OS Places for
|
||||
addresses) and add selected addresses to a Portfolio directly — before any
|
||||
modelling exists.
|
||||
|
||||
## Decision
|
||||
|
||||
- **Next.js inserts `property` rows directly**: address, postcode, uprn,
|
||||
per-property lat/lng, local_authority (postcodes.io `admin_district`),
|
||||
constituency (postcodes.io `parliamentary_constituency`). Nothing inferred —
|
||||
no type/age/tenure guesses on the row. Energy state is derived: no EPC-graph
|
||||
rows ⇒ "awaiting modelling" (mirrors ADR on app-authored scenarios).
|
||||
- Dedup rides the existing partial unique index `uq_property_portfolio_uprn`
|
||||
via `ON CONFLICT DO NOTHING`; the submit reports added vs already-existed.
|
||||
- **Both override layers are written**, mimicking the finaliser: a
|
||||
VocabularyMapping upsert per distinct OS classification code and a
|
||||
`property_overrides` snapshot per property (original description = the OS
|
||||
code). New `override_source` enum value **`os_places`** — the deterministic
|
||||
OS-code mapping is not the LLM "classifier" and provenance must stay
|
||||
distinguishable.
|
||||
- **Deterministic mapping** (fixed table, tested): RD02→House+Detached,
|
||||
RD03→House+Semi-Detached, RD04→House (Mid/End unknowable — no built form),
|
||||
RD06→Flat, RD07→House. Every other code maps nothing; `Unknown` is never
|
||||
written (absence, not noise) — preserving the finaliser's invariant. The
|
||||
backend's predict-EPC service consumes these for archetype matching.
|
||||
- **Read-through cache** on the existing `postcode_search` jsonb table:
|
||||
serve when fresher than 30 days, else refresh from OS Places and upsert.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Routing through the BulkUpload pipeline (synthesise a one-postcode "upload"):
|
||||
full provenance for free, but drags an interactive journey through an async
|
||||
multi-stage state machine built for files.
|
||||
- Property row carries property_type directly: bypasses the override layers the
|
||||
Model backend actually consumes, and creates a second source of truth.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A third Property creator exists; anything assuming "property ⇒ came from a
|
||||
BulkUpload or the backend" is now wrong.
|
||||
- `property_overrides` gains a second writer with `os_places` provenance;
|
||||
re-classification tooling must not clobber user rows (existing rule) nor
|
||||
treat OS rows as user-authored.
|
||||
- Postcode-search-created properties render blank/awaiting-modelling in the
|
||||
portfolio table and reporting until a scenario is modelled against them.
|
||||
197
src/app/api/portfolio/[portfolioId]/properties/route.ts
Normal file
197
src/app/api/portfolio/[portfolioId]/properties/route.ts
Normal file
|
|
@ -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<typeof bodySchema>;
|
||||
try {
|
||||
body = bodySchema.parse(await request.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const plans: ReturnType<typeof buildWritePlan>[] = [];
|
||||
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<string, string>();
|
||||
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<string, string>();
|
||||
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 });
|
||||
}
|
||||
}
|
||||
126
src/app/api/portfolio/[portfolioId]/properties/search/route.ts
Normal file
126
src/app/api/portfolio/[portfolioId]/properties/search/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface PostcodeLookupResult {
|
|||
country: string;
|
||||
region: string | null;
|
||||
admin_district: string | null;
|
||||
parliamentary_constituency: string | null;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<BulkUploadComingSoonModal
|
||||
isOpen={isBulkUploadOpen}
|
||||
onClose={() => setIsBulkUploadOpen(false)}
|
||||
portfolioId={portfolioId}
|
||||
/>
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<MenuButton
|
||||
className="
|
||||
inline-flex items-center gap-1 px-4 py-2 rounded-md
|
||||
bg-gray-50 text-gray-900 hover:bg-midblue hover:text-gray-100
|
||||
transition-colors text-xs font-medium
|
||||
"
|
||||
>
|
||||
<DocumentPlusIcon className="h-4 w-4 mr-2" />
|
||||
New Property
|
||||
<ChevronDownIcon className="h-4 w-4 opacity-70" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuItems
|
||||
className="
|
||||
absolute right-0 mt-3 w-72 origin-top-right rounded-md
|
||||
bg-white shadow-lg ring-1 ring-black/5 focus:outline-none
|
||||
z-[9999] py-3
|
||||
"
|
||||
>
|
||||
<div className="flex flex-col gap-2 px-3">
|
||||
{/* Remote Assessment */}
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={handleRemoteAssessment}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
|
||||
active && "bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<DocumentMagnifyingGlassIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
||||
Remote Assessment
|
||||
{loadingRemote && (
|
||||
<span className="h-3 w-3 rounded-full border-2 border-gray-400 border-t-transparent animate-spin"></span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 leading-snug">
|
||||
Run a remote assessment for a single property.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
|
||||
{/* CSV Upload */}
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => setIsUploadCsvOpen(!isUploadCsvOpen)}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
|
||||
active && "bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<TableCellsIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
File Import
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 leading-snug">
|
||||
For bulk uploads, please contact a Domna user.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
|
||||
{/* Bulk Upload (Coming Soon) */}
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={handleBulkUploadClick}
|
||||
className={cn(
|
||||
"w-full p-3 rounded-lg text-left flex gap-3 transition-colors",
|
||||
active && "bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<RectangleStackIcon className="h-5 w-5 text-gray-700 mt-[2px]" />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
||||
new: Bulk upload
|
||||
<span className="text-[10px] font-semibold text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded-full leading-none">
|
||||
coming soon
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 leading-snug">
|
||||
Upload multiple addresses in one go.
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
|
|
@ -110,11 +105,6 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
|
|||
);
|
||||
})}
|
||||
<YourProjectsDropdown portfolioId={portfolioId} />
|
||||
<AddNewDropDown
|
||||
portfolioId={portfolioId}
|
||||
isUploadCsvOpen={modalIsOpen}
|
||||
setIsUploadCsvOpen={setModalIsOpen}
|
||||
/>
|
||||
{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) {
|
|||
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenu>
|
||||
|
||||
<UploadCsvModal
|
||||
isOpen={modalIsOpen}
|
||||
setIsOpen={setModalIsOpen}
|
||||
portfolioId={portfolioId}
|
||||
scenarios={scenarios}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<section>
|
||||
|
|
@ -29,7 +27,7 @@ export default async function PortfolioLayout(props: {
|
|||
<div className="flex justify-center">
|
||||
<div className="grid grid-cols-8 w-full max-w-8xl">
|
||||
<div className="col-span-12 justify-center bg-gray-50 py-1 px-4 relative">
|
||||
<Toolbar portfolioId={portfolioId} scenarios={scenarios} />
|
||||
<Toolbar portfolioId={portfolioId} />
|
||||
</div>
|
||||
<div className="col-span-12">
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="text-center text-gray-400">
|
||||
<HomeIcon className="h-16 w-16 mx-auto mb-4 text-gray-200" />
|
||||
<p>
|
||||
Hover over <strong>“New Property”</strong> to start adding
|
||||
Use <strong>“Add properties”</strong> to start adding
|
||||
properties to your portfolio.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -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
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Add properties */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-1.5 h-8 px-3 rounded-lg bg-primary text-white text-xs font-semibold hover:opacity-90 transition">
|
||||
<PlusIcon className="h-3.5 w-3.5" />
|
||||
Add properties
|
||||
<ChevronDownIcon className="h-3.5 w-3.5 opacity-70" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72 p-1.5">
|
||||
<DropdownMenuItem
|
||||
className="flex items-start gap-3 cursor-pointer rounded-lg p-3"
|
||||
onSelect={() =>
|
||||
router.push(`/portfolio/${portfolioId}/properties/add`)
|
||||
}
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-[18px] w-[18px] mt-0.5 text-gray-700 shrink-0" />
|
||||
<span>
|
||||
<span className="block text-sm font-semibold text-gray-900">
|
||||
Search by postcode
|
||||
</span>
|
||||
<span className="block text-xs text-slate-400">
|
||||
Find addresses and add them in a few clicks
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled
|
||||
className="flex items-start gap-3 rounded-lg p-3"
|
||||
>
|
||||
<DocumentArrowUpIcon className="h-[18px] w-[18px] mt-0.5 text-gray-400 shrink-0" />
|
||||
<span className="flex-1">
|
||||
<span className="block text-sm font-semibold text-gray-500">
|
||||
Bulk excel import
|
||||
</span>
|
||||
<span className="block text-xs text-slate-400">
|
||||
Upload a spreadsheet of addresses
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide bg-amber-100 text-amber-800 rounded-full px-2 py-0.5 shrink-0">
|
||||
Soon
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
411
src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx
Normal file
411
src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
"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<string, SearchCandidate> }
|
||||
>;
|
||||
|
||||
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<string | null>(null);
|
||||
const [search, setSearch] = useState<{ pc: string; refresh: number } | null>(null);
|
||||
const [basket, setBasket] = useState<Basket>({});
|
||||
const [done, setDone] = useState<{ added: number; skipped: number } | null>(null);
|
||||
|
||||
const query = useQuery<SearchResponse, Error>({
|
||||
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 (
|
||||
<div className="max-w-3xl mx-auto px-6 pt-10 pb-40">
|
||||
{/* Breadcrumb */}
|
||||
<div className="text-xs text-slate-400 mb-4">
|
||||
<Link href={`/portfolio/${portfolioId}`} className="hover:text-primary">
|
||||
{portfolioName} / Portfolio
|
||||
</Link>{" "}
|
||||
/ Add properties
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold text-gray-800 tracking-tight mb-3">
|
||||
Bring your properties in.
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 max-w-xl leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{/* Search card */}
|
||||
<div className="mt-8 bg-white border border-slate-200 rounded-2xl p-5 shadow-sm">
|
||||
<span className="block text-[11px] font-black uppercase tracking-wider text-primary mb-3">
|
||||
Search by postcode
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") doSearch();
|
||||
}}
|
||||
placeholder='e.g. "M20 4TF"'
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={doSearch}
|
||||
disabled={query.isFetching}
|
||||
className="flex items-center gap-1.5 px-5 rounded-xl bg-primary text-white text-sm font-semibold hover:opacity-90 transition disabled:opacity-50"
|
||||
>
|
||||
<MagnifyingGlassIcon className="h-4 w-4" />
|
||||
{query.isFetching ? "Searching…" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-slate-400">
|
||||
{inputError ? (
|
||||
<span className="text-red-600">{inputError}</span>
|
||||
) : query.isError ? (
|
||||
<span className="text-red-600">{query.error.message}</span>
|
||||
) : data ? (
|
||||
data.source === "cache" ? (
|
||||
<>
|
||||
Results from our cache,{" "}
|
||||
<b className="text-slate-600 font-semibold">
|
||||
{data.cacheAgeDays === 0
|
||||
? "refreshed today"
|
||||
: `${data.cacheAgeDays} day${data.cacheAgeDays === 1 ? "" : "s"} old`}
|
||||
</b>{" "}
|
||||
·{" "}
|
||||
<button
|
||||
onClick={() =>
|
||||
setSearch((s) => s && { ...s, refresh: s.refresh + 1 })
|
||||
}
|
||||
className="text-primary font-semibold hover:underline"
|
||||
>
|
||||
Refresh from source
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>Fresh from the national register just now</>
|
||||
)
|
||||
) : (
|
||||
<>We look every postcode up once and remember it — repeat searches are instant.</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Done state */}
|
||||
{done && (
|
||||
<div className="mt-6 bg-white border border-slate-200 rounded-2xl px-8 py-12 text-center shadow-sm">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-amber-100 text-amber-800 mb-4">
|
||||
<CheckIcon className="h-7 w-7" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">
|
||||
{done.added} {done.added === 1 ? "property" : "properties"} added
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 max-w-md mx-auto leading-relaxed mb-2">
|
||||
They're in the portfolio now, marked{" "}
|
||||
<span className="inline-flex items-center gap-1.5 bg-amber-100 text-amber-800 rounded-full px-2.5 py-0.5 text-xs font-semibold">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-800" />
|
||||
Awaiting modelling
|
||||
</span>{" "}
|
||||
— addresses are real from day one; energy data arrives when you run
|
||||
a scenario against them.
|
||||
</p>
|
||||
{done.skipped > 0 && (
|
||||
<p className="text-xs text-slate-400 mb-2">
|
||||
{done.skipped} {done.skipped === 1 ? "was" : "were"} already in
|
||||
the portfolio and left untouched.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-5 flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setDone(null)}
|
||||
className="px-4 py-2 rounded-xl border border-slate-200 bg-white text-sm font-semibold text-primary hover:bg-slate-50 transition"
|
||||
>
|
||||
Add more properties
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(`/portfolio/${portfolioId}`)}
|
||||
className="px-4 py-2 rounded-xl bg-primary text-white text-sm font-semibold hover:opacity-90 transition"
|
||||
>
|
||||
View portfolio →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{!done && !data && !query.isFetching && (
|
||||
<div className="mt-6 border-2 border-dashed border-slate-200 rounded-2xl py-10 text-center text-sm text-slate-400">
|
||||
Searched addresses appear here — tick the ones that belong to this
|
||||
portfolio.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!done && data && (
|
||||
<div className="mt-6 bg-white border border-slate-200 rounded-2xl p-5 shadow-sm">
|
||||
<div className="flex items-baseline gap-3 flex-wrap mb-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">{data.postcode}</h2>
|
||||
<span className="text-xs text-slate-400 font-medium">
|
||||
{data.candidates.length}{" "}
|
||||
{data.candidates.length === 1 ? "address" : "addresses"}
|
||||
</span>
|
||||
{addable.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() =>
|
||||
setBasket((prev) => ({
|
||||
...prev,
|
||||
[data.postcode]: {
|
||||
geo: data.geo,
|
||||
byUprn: Object.fromEntries(
|
||||
addable.map((c) => [c.uprn, c]),
|
||||
),
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="text-xs font-semibold text-primary hover:underline"
|
||||
>
|
||||
Add all
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setBasket((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[data.postcode];
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className="text-xs font-semibold text-primary hover:underline"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-slate-500 tabular-nums">
|
||||
{Object.keys(selected).length} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Long postcodes can return ~100 addresses — scroll inside the card
|
||||
rather than growing the page. */}
|
||||
<div className="max-h-[26rem] overflow-y-auto pr-1 -mr-1">
|
||||
{data.candidates.map((c) => {
|
||||
const { label, note } = describeCandidate(c);
|
||||
const disabled = !c.selectable || c.alreadyInPortfolio;
|
||||
const isSelected = !!selected[c.uprn];
|
||||
return (
|
||||
<button
|
||||
key={c.uprn}
|
||||
disabled={disabled}
|
||||
onClick={() => toggle(data.postcode, data.geo, c)}
|
||||
className={[
|
||||
"w-full flex items-center gap-3 text-left px-4 py-3 rounded-xl border mb-1.5 transition",
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed bg-slate-50 border-transparent"
|
||||
: isSelected
|
||||
? "bg-white border-primary shadow-sm"
|
||||
: "bg-slate-50 border-transparent hover:bg-slate-100",
|
||||
].join(" ")}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
disabled={disabled}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="h-4 w-4 accent-black pointer-events-none shrink-0"
|
||||
/>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-medium text-gray-800 truncate">
|
||||
{c.address}
|
||||
</span>
|
||||
{note && !disabled && (
|
||||
<span className="block text-xs text-slate-400">{note}</span>
|
||||
)}
|
||||
</span>
|
||||
{disabled && (
|
||||
<span className="text-[11px] text-slate-400 font-medium shrink-0">
|
||||
{c.alreadyInPortfolio ? "Already in portfolio" : "Not residential"}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={[
|
||||
"text-[10px] font-bold uppercase tracking-wide rounded-full px-2.5 py-1 shrink-0",
|
||||
disabled
|
||||
? "bg-slate-200 text-slate-500"
|
||||
: "bg-amber-100 text-amber-900",
|
||||
].join(" ")}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection tray */}
|
||||
{totalSelected > 0 && !done && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 w-[min(56rem,calc(100vw-2rem))] bg-white/95 backdrop-blur border border-slate-200 rounded-2xl shadow-2xl px-4 py-3 flex items-center gap-3 flex-wrap">
|
||||
{/* Many postcodes: chips scroll inside the tray instead of growing it */}
|
||||
<div className="flex items-center gap-2 flex-wrap max-h-16 overflow-y-auto">
|
||||
{postcodes.map((pc) => (
|
||||
<span
|
||||
key={pc}
|
||||
className="inline-flex items-center gap-2 bg-slate-100 rounded-full pl-3 pr-1.5 py-1 text-xs font-semibold text-gray-700"
|
||||
>
|
||||
{pc} · {Object.keys(basket[pc].byUprn).length}
|
||||
<button
|
||||
aria-label={`Remove ${pc}`}
|
||||
onClick={() =>
|
||||
setBasket((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[pc];
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className="w-[18px] h-[18px] rounded-full bg-white text-slate-500 text-[10px] leading-none px-1 py-0.5 hover:text-slate-800"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">
|
||||
<b className="text-gray-800">{totalSelected}</b>{" "}
|
||||
{totalSelected === 1 ? "property" : "properties"} across{" "}
|
||||
{postcodes.length} {postcodes.length === 1 ? "postcode" : "postcodes"}
|
||||
</span>
|
||||
{submit.isError && (
|
||||
<span className="text-xs text-red-600">{submit.error.message}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => submit.mutate(basket)}
|
||||
disabled={submit.isLoading}
|
||||
className="ml-auto px-4 py-2 rounded-xl bg-primary text-white text-sm font-semibold hover:opacity-90 transition disabled:opacity-50"
|
||||
>
|
||||
{submit.isLoading
|
||||
? "Adding…"
|
||||
: `Add ${totalSelected} ${totalSelected === 1 ? "property" : "properties"}`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/app/portfolio/[slug]/properties/add/page.tsx
Normal file
11
src/app/portfolio/[slug]/properties/add/page.tsx
Normal file
|
|
@ -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 <AddPropertiesClient portfolioId={slug} portfolioName={portfolioName ?? "Portfolio"} />;
|
||||
}
|
||||
166
src/lib/postcodeSearch/model.test.ts
Normal file
166
src/lib/postcodeSearch/model.test.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWritePlan,
|
||||
cacheAgeDays,
|
||||
classifyOsCode,
|
||||
describeCandidate,
|
||||
isCacheFresh,
|
||||
normalisePostcode,
|
||||
postcodeCacheKeys,
|
||||
toAddressCandidates,
|
||||
} from "./model";
|
||||
|
||||
describe("classifyOsCode", () => {
|
||||
it("maps the five confident residential codes per ADR-0007", () => {
|
||||
expect(classifyOsCode("RD02")).toEqual({ selectable: true, propertyType: "House", builtForm: "Detached" });
|
||||
expect(classifyOsCode("RD03")).toEqual({ selectable: true, propertyType: "House", builtForm: "Semi-Detached" });
|
||||
// Terraced: mid/end unknowable — type only
|
||||
expect(classifyOsCode("RD04")).toEqual({ selectable: true, propertyType: "House", builtForm: null });
|
||||
expect(classifyOsCode("RD06")).toEqual({ selectable: true, propertyType: "Flat", builtForm: null });
|
||||
expect(classifyOsCode("RD07")).toEqual({ selectable: true, propertyType: "House", builtForm: null });
|
||||
});
|
||||
|
||||
it("keeps other residential codes selectable but writes no facts — never Unknown", () => {
|
||||
for (const code of ["RD", "RD01", "RD08"]) {
|
||||
expect(classifyOsCode(code)).toEqual({ selectable: true, propertyType: null, builtForm: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("marks non-residential and institutional codes unselectable", () => {
|
||||
expect(classifyOsCode("CR08").selectable).toBe(false); // commercial retail
|
||||
expect(classifyOsCode("RD10").selectable).toBe(false); // residential institution
|
||||
expect(classifyOsCode(null).selectable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCacheFresh", () => {
|
||||
it("serves cache younger than 30 days, refreshes older", () => {
|
||||
const now = new Date("2026-07-06T12:00:00Z");
|
||||
expect(isCacheFresh(new Date("2026-06-10T12:00:00Z"), now)).toBe(true);
|
||||
expect(isCacheFresh(new Date("2026-06-06T11:00:00Z"), now)).toBe(false);
|
||||
expect(isCacheFresh(null, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAddressCandidates", () => {
|
||||
const items = [
|
||||
{ DPA: { UPRN: "100", ADDRESS: "16 ALDER GROVE, MANCHESTER, M20 4TF", POSTCODE: "M20 4TF",
|
||||
CLASSIFICATION_CODE: "RD02", LAT: 53.43, LNG: -2.23 } },
|
||||
// LPI-only record — still usable
|
||||
{ LPI: { UPRN: "101", ADDRESS: "18 ALDER GROVE, MANCHESTER, M20 4TF", POSTCODE_LOCATOR: "M20 4TF",
|
||||
CLASSIFICATION_CODE: "CR08", LAT: 53.44, LNG: -2.24 } },
|
||||
// duplicate UPRN (DPA + LPI for same address) — deduped, DPA wins
|
||||
{ LPI: { UPRN: "100", ADDRESS: "16 ALDER GROVE ALT, M20 4TF", CLASSIFICATION_CODE: "RD02" } },
|
||||
];
|
||||
it("prefers DPA, dedupes by UPRN, strips the postcode from the address", () => {
|
||||
const out = toAddressCandidates(items as never, "M20 4TF");
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0]).toMatchObject({
|
||||
uprn: "100", address: "16 Alder Grove, Manchester", postcode: "M20 4TF",
|
||||
classificationCode: "RD02", lat: 53.43, lng: -2.23, selectable: true,
|
||||
propertyType: "House", builtForm: "Detached",
|
||||
});
|
||||
expect(out[1]).toMatchObject({ uprn: "101", selectable: false });
|
||||
});
|
||||
|
||||
it("orders addresses numerically, so Flat 2 comes before Flat 10", () => {
|
||||
const flats = [
|
||||
{ DPA: { UPRN: "1", ADDRESS: "FLAT 10, BIRCH COURT, M20 4TF", CLASSIFICATION_CODE: "RD06" } },
|
||||
{ DPA: { UPRN: "2", ADDRESS: "FLAT 2, BIRCH COURT, M20 4TF", CLASSIFICATION_CODE: "RD06" } },
|
||||
{ DPA: { UPRN: "3", ADDRESS: "FLAT 1, BIRCH COURT, M20 4TF", CLASSIFICATION_CODE: "RD06" } },
|
||||
{ DPA: { UPRN: "4", ADDRESS: "3 BIRCH LANE, M20 4TF", CLASSIFICATION_CODE: "RD02" } },
|
||||
{ DPA: { UPRN: "5", ADDRESS: "12 BIRCH LANE, M20 4TF", CLASSIFICATION_CODE: "RD02" } },
|
||||
];
|
||||
const out = toAddressCandidates(flats as never, "M20 4TF");
|
||||
expect(out.map((c) => c.address)).toEqual([
|
||||
"3 Birch Lane",
|
||||
"12 Birch Lane",
|
||||
"Flat 1, Birch Court",
|
||||
"Flat 2, Birch Court",
|
||||
"Flat 10, Birch Court",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWritePlan", () => {
|
||||
const geo = { localAuthority: "Manchester", constituency: "Manchester Withington" };
|
||||
it("builds the property row and both override layers for a mapped candidate", () => {
|
||||
const plan = buildWritePlan(
|
||||
{ uprn: "100", address: "16 Alder Grove, Manchester", postcode: "M20 4TF",
|
||||
classificationCode: "RD02", lat: 53.43, lng: -2.23, selectable: true,
|
||||
propertyType: "House", builtForm: "Detached" },
|
||||
geo,
|
||||
);
|
||||
expect(plan.property).toMatchObject({
|
||||
address: "16 Alder Grove, Manchester", postcode: "M20 4TF", uprn: 100n,
|
||||
latitude: 53.43, longitude: -2.23,
|
||||
localAuthority: "Manchester", constituency: "Manchester Withington",
|
||||
});
|
||||
// both facts, snapshot keyed to the OS code, main building part
|
||||
expect(plan.overrides).toEqual([
|
||||
{ component: "property_type", value: "House", originalDescription: "RD02", buildingPart: 0 },
|
||||
{ component: "built_form_type", value: "Detached", originalDescription: "RD02", buildingPart: 0 },
|
||||
]);
|
||||
});
|
||||
it("writes no overrides when the code maps nothing — never Unknown", () => {
|
||||
const plan = buildWritePlan(
|
||||
{ uprn: "200", address: "Birch Court", postcode: "M20 4UD", classificationCode: "RD",
|
||||
lat: null, lng: null, selectable: true, propertyType: null, builtForm: null },
|
||||
geo,
|
||||
);
|
||||
expect(plan.overrides).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
expect(normalisePostcode(" sw1a1aa ")).toBe("SW1A 1AA");
|
||||
expect(normalisePostcode("M20 4TF")).toBe("M20 4TF");
|
||||
});
|
||||
|
||||
it("returns null for strings that are not plausible postcodes", () => {
|
||||
expect(normalisePostcode("")).toBeNull();
|
||||
expect(normalisePostcode("not a postcode")).toBeNull();
|
||||
expect(normalisePostcode("12345")).toBeNull();
|
||||
});
|
||||
});
|
||||
192
src/lib/postcodeSearch/model.ts
Normal file
192
src/lib/postcodeSearch/model.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
/**
|
||||
* Postcode search domain model — pure logic for the add-properties-by-postcode
|
||||
* journey. See CONTEXT.md (Postcode search) and ADR-0007.
|
||||
*/
|
||||
|
||||
/** Canonical form: uppercase, single space before the inward code. Null if implausible. */
|
||||
export function normalisePostcode(input: string): string | null {
|
||||
const m = input.trim().toUpperCase().replace(/\s+/g, "").match(
|
||||
/^([A-Z]{1,2}\d[A-Z\d]?)(\d[A-Z]{2})$/,
|
||||
);
|
||||
return m ? `${m[1]} ${m[2]}` : null;
|
||||
}
|
||||
|
||||
export interface OsClassification {
|
||||
selectable: boolean;
|
||||
propertyType: string | null;
|
||||
builtForm: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic OS classification-code mapping (ADR-0007). Only codes with a
|
||||
* confident enum equivalent write facts; "Unknown" is never produced — an
|
||||
* unmappable residential code stays selectable with no facts. RD10
|
||||
* (residential institutions) and all non-RD codes are not addable.
|
||||
*/
|
||||
const OS_CODE_FACTS: Record<string, { propertyType: string; builtForm: string | null }> = {
|
||||
RD02: { propertyType: "House", builtForm: "Detached" },
|
||||
RD03: { propertyType: "House", builtForm: "Semi-Detached" },
|
||||
RD04: { propertyType: "House", builtForm: null }, // terraced — mid/end unknowable
|
||||
RD06: { propertyType: "Flat", builtForm: null },
|
||||
RD07: { propertyType: "House", builtForm: null }, // HMO
|
||||
};
|
||||
|
||||
const CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Read-through cache rule: serve postcode_search rows younger than 30 days. */
|
||||
export function isCacheFresh(fetchedAt: Date | null, now: Date): boolean {
|
||||
return !!fetchedAt && now.getTime() - fetchedAt.getTime() < CACHE_MAX_AGE_MS;
|
||||
}
|
||||
|
||||
type OsItem = { DPA?: Record<string, unknown>; LPI?: Record<string, unknown> };
|
||||
|
||||
export interface AddressCandidate {
|
||||
uprn: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
classificationCode: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
selectable: boolean;
|
||||
propertyType: string | null;
|
||||
builtForm: string | null;
|
||||
}
|
||||
|
||||
const titleCase = (s: string) =>
|
||||
s.toLowerCase().replace(/\b[a-z]/g, (c) => c.toUpperCase());
|
||||
|
||||
/**
|
||||
* Flatten OS Places items into unique address candidates: DPA preferred over
|
||||
* LPI for the same UPRN; address rendered in title case with the postcode
|
||||
* suffix stripped (it lives in its own column); ordered naturally so
|
||||
* "Flat 2" sorts before "Flat 10".
|
||||
*/
|
||||
export function toAddressCandidates(
|
||||
items: OsItem[],
|
||||
postcode: string,
|
||||
): AddressCandidate[] {
|
||||
const byUprn = new Map<string, { rec: Record<string, unknown>; isDpa: boolean }>();
|
||||
for (const item of items) {
|
||||
const rec = item.DPA ?? item.LPI;
|
||||
if (!rec || rec.UPRN == null) continue;
|
||||
const uprn = String(rec.UPRN);
|
||||
const existing = byUprn.get(uprn);
|
||||
if (!existing || (item.DPA && !existing.isDpa)) {
|
||||
byUprn.set(uprn, { rec, isDpa: !!item.DPA });
|
||||
}
|
||||
}
|
||||
return [...byUprn.values()].map(({ rec }) => {
|
||||
const code = (rec.CLASSIFICATION_CODE as string) ?? null;
|
||||
const cls = classifyOsCode(code);
|
||||
const raw = String(rec.ADDRESS ?? "");
|
||||
const address = titleCase(
|
||||
raw.replace(new RegExp(`,?\\s*${postcode.replace(" ", "\\s*")}\\s*$`, "i"), ""),
|
||||
);
|
||||
return {
|
||||
uprn: String(rec.UPRN),
|
||||
address,
|
||||
postcode,
|
||||
classificationCode: code,
|
||||
lat: typeof rec.LAT === "number" ? rec.LAT : null,
|
||||
lng: typeof rec.LNG === "number" ? rec.LNG : null,
|
||||
...cls,
|
||||
};
|
||||
}).sort((a, b) =>
|
||||
a.address.localeCompare(b.address, "en", { numeric: true, sensitivity: "base" }),
|
||||
);
|
||||
}
|
||||
|
||||
export interface PostcodeGeo {
|
||||
localAuthority: string | null;
|
||||
constituency: string | null;
|
||||
}
|
||||
|
||||
/** The rows a selected candidate produces: one property + 0–2 override facts (ADR-0007). */
|
||||
export function buildWritePlan(c: AddressCandidate, geo: PostcodeGeo) {
|
||||
const overrides: {
|
||||
component: "property_type" | "built_form_type";
|
||||
value: string;
|
||||
originalDescription: string;
|
||||
buildingPart: number;
|
||||
}[] = [];
|
||||
if (c.propertyType && c.classificationCode) {
|
||||
overrides.push({
|
||||
component: "property_type",
|
||||
value: c.propertyType,
|
||||
originalDescription: c.classificationCode,
|
||||
buildingPart: 0,
|
||||
});
|
||||
}
|
||||
if (c.builtForm && c.classificationCode) {
|
||||
overrides.push({
|
||||
component: "built_form_type",
|
||||
value: c.builtForm,
|
||||
originalDescription: c.classificationCode,
|
||||
buildingPart: 0,
|
||||
});
|
||||
}
|
||||
return {
|
||||
property: {
|
||||
address: c.address,
|
||||
postcode: c.postcode,
|
||||
uprn: BigInt(c.uprn),
|
||||
latitude: c.lat,
|
||||
longitude: c.lng,
|
||||
localAuthority: geo.localAuthority,
|
||||
constituency: geo.constituency,
|
||||
},
|
||||
overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
const residential = c === "RD" || (c.startsWith("RD") && c !== "RD10");
|
||||
if (!residential) return { selectable: false, propertyType: null, builtForm: null };
|
||||
const facts = OS_CODE_FACTS[c];
|
||||
return {
|
||||
selectable: true,
|
||||
propertyType: facts?.propertyType ?? null,
|
||||
builtForm: facts?.builtForm ?? null,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue