mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-19 17:02:59 +00:00
feat(postcode-search): routes, add-properties page, PropertyTable entry point
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 <noreply@anthropic.com>
This commit is contained in:
parent
463e17debc
commit
8a0ad0c0dc
12 changed files with 876 additions and 182 deletions
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>
|
||||
|
||||
|
|
|
|||
401
src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx
Normal file
401
src/app/portfolio/[slug]/properties/add/AddPropertiesClient.tsx
Normal file
|
|
@ -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<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
|
||||
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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
{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>
|
||||
))}
|
||||
<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"} />;
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue