mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
Merge pull request #104 from Hestia-Homes/remote-assessment-ui
Remote assessment UI
This commit is contained in:
commit
be08df8f05
24 changed files with 5532 additions and 1030 deletions
18
src/app/api/postcode/FormatOsResults.ts
Normal file
18
src/app/api/postcode/FormatOsResults.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Extracts simplified address list from OS Places response.
|
||||
*/
|
||||
export function formatOsResults(apiResponse: any) {
|
||||
if (!apiResponse?.results) return [];
|
||||
|
||||
return apiResponse.results
|
||||
.map((entry: any) => {
|
||||
const dpa = entry.DPA;
|
||||
if (!dpa) return null;
|
||||
|
||||
return {
|
||||
uprn: dpa.UPRN,
|
||||
address: dpa.ADDRESS,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
148
src/app/api/postcode/LookupOsPlaces.ts
Normal file
148
src/app/api/postcode/LookupOsPlaces.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { OSPlacesHeader, OSPlacesResponse } from "@/app/db/schema/addresses";
|
||||
|
||||
const OS_API_URL = "https://api.os.uk/search/places/v1/postcode";
|
||||
const OS_API_KEY = process.env.OS_API_KEY!;
|
||||
|
||||
type OSPlacesItem = { DPA?: Record<string, any>; LPI?: Record<string, any> };
|
||||
|
||||
export interface OSPlacesResult {
|
||||
status: number;
|
||||
// First page response (kept for completeness)
|
||||
data?: OSPlacesResponse;
|
||||
// All results across pages (flattened)
|
||||
results?: OSPlacesItem[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single page from OS Places
|
||||
*/
|
||||
async function fetchOsPlacesPage(
|
||||
postcode: string,
|
||||
offset: number,
|
||||
maxResults: number,
|
||||
retries: number,
|
||||
delay: number
|
||||
): Promise<{ status: number; data?: OSPlacesResponse; error?: string }> {
|
||||
const url = `${OS_API_URL}?postcode=${encodeURIComponent(
|
||||
postcode
|
||||
)}&key=${OS_API_KEY}&maxresults=${maxResults}&offset=${offset}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { method: "GET" });
|
||||
|
||||
if (res.ok) {
|
||||
const data: OSPlacesResponse = await res.json();
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
// Retry on transient issues (rate limit, server errors)
|
||||
if ([429, 500, 503].includes(res.status) && retries > 0) {
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
return fetchOsPlacesPage(
|
||||
postcode,
|
||||
offset,
|
||||
maxResults,
|
||||
retries - 1,
|
||||
delay * 2
|
||||
);
|
||||
}
|
||||
|
||||
// Map known errors
|
||||
let errorMessage = "Unexpected error";
|
||||
switch (res.status) {
|
||||
case 400:
|
||||
errorMessage = "Bad request — malformed postcode or query";
|
||||
break;
|
||||
case 401:
|
||||
errorMessage = "Unauthorized — check OS API key";
|
||||
break;
|
||||
case 403:
|
||||
errorMessage = "Forbidden — insufficient permissions for API access";
|
||||
break;
|
||||
case 404:
|
||||
errorMessage = "Postcode not found";
|
||||
break;
|
||||
case 405:
|
||||
errorMessage = "Method not allowed";
|
||||
break;
|
||||
case 429:
|
||||
errorMessage = "Rate limit exceeded — too many requests";
|
||||
break;
|
||||
case 503:
|
||||
errorMessage = "Service temporarily unavailable";
|
||||
break;
|
||||
default:
|
||||
errorMessage = `Unhandled error (${res.status})`;
|
||||
}
|
||||
return { status: res.status, error: errorMessage };
|
||||
} catch (err: any) {
|
||||
if (retries > 0) {
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
return fetchOsPlacesPage(
|
||||
postcode,
|
||||
offset,
|
||||
maxResults,
|
||||
retries - 1,
|
||||
delay * 2
|
||||
);
|
||||
}
|
||||
return { status: 500, error: err?.message || "Network error" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the Ordnance Survey Places API for a given postcode,
|
||||
* with offset-based pagination, retry handling, and structured errors.
|
||||
*/
|
||||
export async function lookupOsPlaces(
|
||||
postcode: string,
|
||||
{
|
||||
maxResults = 100,
|
||||
retries = 2,
|
||||
delay = 1000,
|
||||
}: { maxResults?: number; retries?: number; delay?: number } = {}
|
||||
): Promise<OSPlacesResult> {
|
||||
const normalized = postcode.toUpperCase().trim();
|
||||
|
||||
// Page 1
|
||||
const first = await fetchOsPlacesPage(
|
||||
normalized,
|
||||
0,
|
||||
maxResults,
|
||||
retries,
|
||||
delay
|
||||
);
|
||||
if (first.error || !first.data) {
|
||||
return { status: first.status, error: first.error };
|
||||
}
|
||||
|
||||
const total =
|
||||
first.data.header?.totalresults ?? first.data.results?.length ?? 0;
|
||||
let allResults: OSPlacesItem[] = first.data.results ?? [];
|
||||
|
||||
// If more pages exist, fetch them in a loop
|
||||
for (let offset = maxResults; offset < total; offset += maxResults) {
|
||||
const page = await fetchOsPlacesPage(
|
||||
normalized,
|
||||
offset,
|
||||
maxResults,
|
||||
retries,
|
||||
delay
|
||||
);
|
||||
if (page.error || !page.data) {
|
||||
// Return what we have so far but indicate partial failure
|
||||
return {
|
||||
status: page.status,
|
||||
data: first.data,
|
||||
results: allResults,
|
||||
error: `Failed to fetch page at offset ${offset}: ${page.error ?? "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
if (page.data.results?.length) {
|
||||
allResults = allResults.concat(page.data.results);
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 200, data: first.data, results: allResults };
|
||||
}
|
||||
59
src/app/api/postcode/LookupPostcode.ts
Normal file
59
src/app/api/postcode/LookupPostcode.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// src/app/utils/postcodes.ts
|
||||
|
||||
export interface PostcodeLookupResult {
|
||||
status: number;
|
||||
result?: {
|
||||
postcode: string;
|
||||
country: string;
|
||||
region: string | null;
|
||||
admin_district: string | null;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a postcode using postcodes.io.
|
||||
* Includes automatic retry logic for transient 5xx errors.
|
||||
*/
|
||||
export async function lookupPostcode(
|
||||
postcode: string,
|
||||
retries = 2
|
||||
): Promise<PostcodeLookupResult> {
|
||||
const url = `https://api.postcodes.io/postcodes/${encodeURIComponent(
|
||||
postcode.trim()
|
||||
)}`;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
// Retry only on transient 5xx errors
|
||||
if (res.status >= 500 && attempt < retries) {
|
||||
console.warn(`Retrying postcode lookup (attempt ${attempt + 1})`);
|
||||
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
|
||||
continue;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
if (attempt < retries) {
|
||||
console.warn(`Network error on attempt ${attempt + 1}, retrying...`);
|
||||
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
|
||||
} else {
|
||||
return {
|
||||
status: 500,
|
||||
error: "Network error while contacting postcodes.io",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Should never reach here
|
||||
return { status: 500, error: "Unexpected error" };
|
||||
}
|
||||
99
src/app/api/postcode/OsPlacesToFlat.ts
Normal file
99
src/app/api/postcode/OsPlacesToFlat.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
export type FlatAddress = {
|
||||
uprn: string;
|
||||
address: string;
|
||||
propertyType?: PropertyType;
|
||||
builtForm?: BuiltForm;
|
||||
};
|
||||
|
||||
export type PropertyType = "House" | "Flat" | "Maisonette" | "Bungalow";
|
||||
export type BuiltForm =
|
||||
| "Detached"
|
||||
| "Semi-Detached"
|
||||
| "Mid-Terrace"
|
||||
| "End-Terrace"
|
||||
| "Enclosed End-Terrace"
|
||||
| "Enclosed Mid-Terrace";
|
||||
|
||||
export function mapOsClassToProperty(classification?: string): {
|
||||
propertyType?: PropertyType;
|
||||
builtForm?: BuiltForm;
|
||||
} {
|
||||
if (!classification) return {};
|
||||
|
||||
const code = classification.toUpperCase();
|
||||
|
||||
if (code.startsWith("RD02")) {
|
||||
return { propertyType: "House", builtForm: "Detached" };
|
||||
}
|
||||
if (code.startsWith("RD03")) {
|
||||
return { propertyType: "House", builtForm: "Semi-Detached" };
|
||||
}
|
||||
if (code.startsWith("RD04")) {
|
||||
return { propertyType: "House", builtForm: "Mid-Terrace" };
|
||||
}
|
||||
if (code.startsWith("RD06")) {
|
||||
return { propertyType: "Flat" };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function inferPropertyFromAddress(address: string): {
|
||||
propertyType?: PropertyType;
|
||||
builtForm?: BuiltForm;
|
||||
} {
|
||||
const addr = address.toLowerCase();
|
||||
|
||||
// Detect explicit mentions
|
||||
if (addr.includes("bungalow")) return { propertyType: "Bungalow" };
|
||||
if (addr.includes("maisonette")) return { propertyType: "Maisonette" };
|
||||
if (addr.includes("flat") || addr.includes("apartment"))
|
||||
return { propertyType: "Flat" };
|
||||
|
||||
// If it says "terrace" in address, but no RD code, assume terraced house
|
||||
if (addr.includes("terrace") || addr.includes("terraced"))
|
||||
return { propertyType: "House", builtForm: "Mid-Terrace" };
|
||||
|
||||
// Minimal fallback if text only shows 'house' or street number
|
||||
if (addr.match(/\d+\s+[a-z]/)) return { propertyType: "House" };
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function mapOsPlacesToFlat(
|
||||
results?: Array<{ DPA?: any; LPI?: any }>
|
||||
): FlatAddress[] {
|
||||
if (!results?.length) return [];
|
||||
|
||||
const items = results
|
||||
.map((r) => r.DPA ?? r.LPI)
|
||||
.filter(Boolean)
|
||||
.map((rec: any) => {
|
||||
const classification = rec.CLASSIFICATION_CODE as string | undefined;
|
||||
const base = mapOsClassToProperty(classification);
|
||||
const textBased = inferPropertyFromAddress(String(rec.ADDRESS ?? ""));
|
||||
|
||||
// Merge — classification has priority, but text fills in missing gaps
|
||||
const propertyType = base.propertyType ?? textBased.propertyType;
|
||||
const builtForm = base.builtForm ?? textBased.builtForm;
|
||||
|
||||
return {
|
||||
uprn: String(rec.UPRN ?? ""),
|
||||
address: String(rec.ADDRESS ?? ""),
|
||||
propertyType,
|
||||
builtForm,
|
||||
};
|
||||
})
|
||||
.filter((x) => x.uprn && x.address);
|
||||
|
||||
// De-duplicate by UPRN
|
||||
const seen = new Set<string>();
|
||||
const deduped: FlatAddress[] = [];
|
||||
for (const it of items) {
|
||||
if (!seen.has(it.uprn)) {
|
||||
seen.add(it.uprn);
|
||||
deduped.push(it);
|
||||
}
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
75
src/app/api/postcode/[postcode]/addresses/route.ts
Normal file
75
src/app/api/postcode/[postcode]/addresses/route.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { db } from "@/app/db/db";
|
||||
import { postcodeSearch } from "@/app/db/schema/addresses";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { lookupOsPlaces } from "@/app/api/postcode/LookupOsPlaces";
|
||||
import { mapOsPlacesToFlat } from "@/app/api/postcode/OsPlacesToFlat";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ postcode: string }> }
|
||||
) {
|
||||
const { postcode } = await props.params;
|
||||
if (!postcode || typeof postcode !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing or invalid postcode" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: check cache
|
||||
const cached = await db
|
||||
.select()
|
||||
.from(postcodeSearch)
|
||||
.where(eq(postcodeSearch.postcode, postcode))
|
||||
.limit(1);
|
||||
|
||||
if (cached.length > 0) {
|
||||
console.log("Using cached OS Places data for postcode:", postcode);
|
||||
const record = cached[0];
|
||||
const addresses = mapOsPlacesToFlat(record.resultData?.results);
|
||||
return NextResponse.json({
|
||||
status: 200,
|
||||
source: "cache",
|
||||
total: record.resultData?.header?.totalresults ?? addresses.length,
|
||||
results: addresses,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 2: if no cache, query OS Places API
|
||||
const result = await lookupOsPlaces(postcode);
|
||||
if (result.error || result.status !== 200 || !result.data) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: result.error ?? "Failed to fetch address data",
|
||||
status: result.status,
|
||||
},
|
||||
{ status: result.status }
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: flatten and cache the result
|
||||
const addresses = mapOsPlacesToFlat(result.results);
|
||||
const total = result.data.header?.totalresults ?? addresses.length;
|
||||
|
||||
await db.insert(postcodeSearch).values({
|
||||
postcode: postcode,
|
||||
resultData: result.data,
|
||||
});
|
||||
|
||||
// Step 4: return results
|
||||
return NextResponse.json({
|
||||
status: 200,
|
||||
source: "live",
|
||||
total,
|
||||
results: addresses,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("Error fetching OS Places data:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/app/api/postcode/[postcode]/route.ts
Normal file
31
src/app/api/postcode/[postcode]/route.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { lookupPostcode } from "../LookupPostcode";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
props: { params: Promise<{ postcode: string }> }
|
||||
) {
|
||||
const { postcode } = await props.params;
|
||||
|
||||
if (!postcode || typeof postcode !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing or invalid postcode" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await lookupPostcode(postcode);
|
||||
|
||||
if (data.status === 404) {
|
||||
return NextResponse.json({ error: "Invalid postcode" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (data.status !== 200 || !data.result) {
|
||||
return NextResponse.json(
|
||||
{ error: data.error || "Postcode lookup failed" },
|
||||
{ status: data.status || 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ result: data.result });
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ import { PropertyMeta } from "@/app/db/schema/property";
|
|||
interface ToolbarProps {
|
||||
propertyId: string;
|
||||
portfolioId: string;
|
||||
propertyMeta: PropertyMeta
|
||||
propertyMeta: PropertyMeta;
|
||||
decentHomes: getUploadedFile;
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +172,7 @@ export function Toolbar({
|
|||
<BookSurveyModal
|
||||
open={openModal}
|
||||
onOpenChange={setOpenModal}
|
||||
propertyId={propertyId}
|
||||
propertyId={BigInt(propertyId)}
|
||||
portfolioId={portfolioId}
|
||||
address={propertyMeta.address}
|
||||
onSuccess={() => setShowToast(true)}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import {
|
|||
NavigationMenuLink,
|
||||
NavigationMenuTrigger,
|
||||
} from "@/app/shadcn_components/ui/navigation-menu";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
PlusIcon,
|
||||
TableCellsIcon,
|
||||
DocumentMagnifyingGlassIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
|
@ -41,32 +41,32 @@ const ListItem = React.forwardRef<
|
|||
ListItem.displayName = "ListItem";
|
||||
|
||||
export default function AddNewDropDown({
|
||||
portfolioId,
|
||||
isUploadCsvOpen,
|
||||
setIsUploadCsvOpen,
|
||||
isRemoteAssessmentOpen,
|
||||
setIsRemoteAssessmentOpen,
|
||||
}: {
|
||||
portfolioId: string;
|
||||
isUploadCsvOpen: boolean;
|
||||
setIsUploadCsvOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isRemoteAssessmentOpen: boolean;
|
||||
setIsRemoteAssessmentOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) {
|
||||
function handleCickAddUnit() {
|
||||
console.log("Add unit");
|
||||
}
|
||||
|
||||
function handleClickUploadCSV() {
|
||||
setIsUploadCsvOpen(!isUploadCsvOpen);
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function handleClickRemoteAssessment() {
|
||||
setIsRemoteAssessmentOpen(!isRemoteAssessmentOpen);
|
||||
router.push(`/portfolio/${portfolioId}/remote-assessment`);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger className="bg-gray-50 text-gray-900">
|
||||
Add New
|
||||
New Property
|
||||
</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<ul className="p-6 md:w-[200px] lg:w-[350px] lg:grid-cols-[.75fr_1fr] cursor-pointer">
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
import AddNewDropDown from "./AddNew";
|
||||
import { cva } from "class-variance-authority";
|
||||
import UploadCsvModal from "@/app/portfolio/[slug]/components/UploadCsvModal";
|
||||
import RemoteAssessmentModal from "@/app/portfolio/[slug]/components/RemoteAssessmentModal";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ScenarioSelect } from "@/app/db/schema/recommendations";
|
||||
|
|
@ -97,18 +96,13 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
|
|||
</NavigationMenuItem>
|
||||
|
||||
<AddNewDropDown
|
||||
portfolioId={portfolioId}
|
||||
isUploadCsvOpen={modalIsOpen}
|
||||
setIsUploadCsvOpen={setModalIsOpen}
|
||||
isRemoteAssessmentOpen={isRemoteAssessmentOpen}
|
||||
setIsRemoteAssessmentOpen={setIsRemoteAssessmentOpen}
|
||||
/>
|
||||
</NavigationMenuList>
|
||||
<RemoteAssessmentModal
|
||||
isOpen={isRemoteAssessmentOpen}
|
||||
setIsOpen={setIsRemoteAssessmentOpen}
|
||||
portfolioId={portfolioId}
|
||||
scenarios={scenarios}
|
||||
/>
|
||||
<UploadCsvModal
|
||||
isOpen={modalIsOpen}
|
||||
setIsOpen={setModalIsOpen}
|
||||
|
|
|
|||
7
src/app/db/migrations/0121_chunky_tony_stark.sql
Normal file
7
src/app/db/migrations/0121_chunky_tony_stark.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE "postcode_search" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"postcode" text NOT NULL,
|
||||
"result_data" jsonb NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "postcode_search_postcode_unique" UNIQUE("postcode")
|
||||
);
|
||||
3875
src/app/db/migrations/meta/0121_snapshot.json
Normal file
3875
src/app/db/migrations/meta/0121_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -848,6 +848,13 @@
|
|||
"when": 1761146299937,
|
||||
"tag": "0120_flashy_puck",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 121,
|
||||
"version": "7",
|
||||
"when": 1761218186670,
|
||||
"tag": "0121_chunky_tony_stark",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
35
src/app/db/schema/addresses.ts
Normal file
35
src/app/db/schema/addresses.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { pgTable, serial, text, jsonb, timestamp } from "drizzle-orm/pg-core";
|
||||
|
||||
// This table stores postcode search results from the OS Places API
|
||||
// for re-use and caching purposes. The data is stored in jsonb format, to
|
||||
// allow for fast queries and flexibility with the API response structure.
|
||||
|
||||
export interface OSPlacesHeader {
|
||||
totalresults?: number;
|
||||
offset?: number;
|
||||
maxresults?: number;
|
||||
[k: string]: any;
|
||||
}
|
||||
|
||||
export interface OSPlacesItem {
|
||||
DPA?: Record<string, any>;
|
||||
LPI?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface OSPlacesResponse {
|
||||
header?: OSPlacesHeader;
|
||||
results?: OSPlacesItem[];
|
||||
}
|
||||
|
||||
export const postcodeSearch = pgTable("postcode_search", {
|
||||
id: serial("id").primaryKey(),
|
||||
|
||||
// Normalized postcode (uppercase, no spaces)
|
||||
postcode: text("postcode").notNull().unique(),
|
||||
|
||||
// Full OS Places API response
|
||||
resultData: jsonb("result_data").$type<OSPlacesResponse>().notNull(),
|
||||
|
||||
// Timestamp for when the entry was first created
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
|
@ -19,9 +19,9 @@ export const RemoteAssessmentFormSchema = baseFormSchema
|
|||
addressLineOne: z.string().min(1),
|
||||
postcode: z.string().min(1),
|
||||
uprn: z.number().min(1, "UPRN must be a valid number"),
|
||||
valuation: z.number().min(1, "Valuation must be a valid number"),
|
||||
propertyType: z.string().nullable(),
|
||||
builtForm: z.string().nullable(),
|
||||
valuation: z.number().min(1, "Valuation must be a valid number").optional(),
|
||||
propertyType: z.string().nullable().optional(),
|
||||
builtForm: z.string().nullable().optional(),
|
||||
})
|
||||
.refine((data) => data.goal !== "Increasing EPC" || !!data.goalValue, {
|
||||
path: ["goalValue"],
|
||||
|
|
@ -50,11 +50,14 @@ export const uploadCsvSchema = baseFormSchema.extend({
|
|||
if (val === "" || val === undefined) return undefined;
|
||||
return Number(val);
|
||||
}, z.number().min(0.1)),
|
||||
budget: z.preprocess((val) => {
|
||||
if (val === "" || val === undefined) return undefined;
|
||||
if (val === null) return null;
|
||||
return Number(val);
|
||||
}, z.union([z.number(), z.null()]).optional()),
|
||||
budget: z.preprocess(
|
||||
(val) => {
|
||||
if (val === "" || val === undefined) return undefined;
|
||||
if (val === null) return null;
|
||||
return Number(val);
|
||||
},
|
||||
z.union([z.number(), z.null()]).optional()
|
||||
),
|
||||
});
|
||||
|
||||
export type UploadCsvFormValues = z.infer<typeof uploadCsvSchema>;
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function SelectScenarioDropdown({
|
|||
<Menu.Button
|
||||
as={Button}
|
||||
variant="default"
|
||||
className="w-full justify-start bg-brandmidblue text-white rounded-lg shadow-sm hover:bg-brandblue focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brandmidblue"
|
||||
className="w-full justify-start bg-brandblue text-white rounded-lg shadow-sm hover:bg-brandblue focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brandmidblue"
|
||||
>
|
||||
{selectedValue === newOption.value && (
|
||||
<PlusIcon className="mr-2 h-5 w-5 text-white" aria-hidden="true" />
|
||||
|
|
@ -157,8 +157,8 @@ export function SelectDropdown({
|
|||
disabled
|
||||
? "cursor-not-allowed text-gray-400"
|
||||
: active
|
||||
? "bg-brandbrown text-white"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
? "bg-brandbrown text-white"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
|
|
|
|||
|
|
@ -1,948 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
Transition,
|
||||
TransitionChild,
|
||||
} from "@headlessui/react";
|
||||
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/app/shadcn_components/ui/form";
|
||||
import { useToast } from "@/app/hooks/use-toast";
|
||||
import { ScenarioSelect } from "@/app/db/schema/recommendations";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
SelectScenarioDropdown,
|
||||
SelectDropdown,
|
||||
} from "./RemoteAssessmentDropdowns";
|
||||
import MeasuresCheckboxes from "./MeasuresCheckboxes";
|
||||
import { measuresList } from "@/app/db/schema/recommendations";
|
||||
import {
|
||||
RemoteAssessmentFormSchema,
|
||||
RemoteAssessmentFormValues,
|
||||
} from "./FormSchema";
|
||||
|
||||
type Option = {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type DropdownProps = {
|
||||
options: Option[];
|
||||
selectedOption: string;
|
||||
onSelectOption: (option: Option) => void;
|
||||
width?: string;
|
||||
};
|
||||
|
||||
// Extend the existing props
|
||||
type OptionalDropdownProps = Omit<DropdownProps, "selectedOption"> & {
|
||||
selectedOption: string | null | undefined;
|
||||
};
|
||||
|
||||
const selecthousingTypeOptions = [
|
||||
{
|
||||
label: "Social",
|
||||
value: "Social",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Private",
|
||||
value: "Private",
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const propertyTypeOptions = [
|
||||
{
|
||||
label: "House",
|
||||
value: "House",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Flat",
|
||||
value: "Flat",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Bungalow",
|
||||
value: "bungalow",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Maisonette",
|
||||
value: "Maisonette",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Other",
|
||||
value: "Other",
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const builtFormOptions = [
|
||||
{
|
||||
label: "Detached",
|
||||
value: "Detached",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Semi-Detached",
|
||||
value: "Semi-Detached",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Mid-Terrace",
|
||||
value: "Mid-Terrace",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "End-Terrace",
|
||||
value: "End-Terrace",
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const selectGoalOptions = [
|
||||
{
|
||||
label: "Increasing EPC",
|
||||
value: "Increasing EPC",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Energy Savings",
|
||||
value: "Energy Savings",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "Reducing CO2 emissions",
|
||||
value: "Reducing CO2 emissions",
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const goalValueOptions = [
|
||||
{
|
||||
label: "C",
|
||||
value: "C",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "B",
|
||||
value: "B",
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: "A",
|
||||
value: "A",
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
interface EngineTriggerBody {
|
||||
portfolio_id: string;
|
||||
housing_type: string;
|
||||
goal: string;
|
||||
goal_value: string | null;
|
||||
trigger_file_path: string;
|
||||
already_installed_file_path: string;
|
||||
patches_file_path: string;
|
||||
non_invasive_recommendations_file_path: string;
|
||||
valuation_file_path: string;
|
||||
scenario_name: string;
|
||||
multi_plan: boolean;
|
||||
budget: number | null;
|
||||
event_type: string;
|
||||
inclusions: (typeof measuresList)[number][];
|
||||
scenario_id?: string | null;
|
||||
}
|
||||
|
||||
async function uploadCsvToS3({
|
||||
presignedUrl,
|
||||
file,
|
||||
}: {
|
||||
presignedUrl: string;
|
||||
file: Blob;
|
||||
}) {
|
||||
try {
|
||||
const response = await fetch(presignedUrl, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: { "Content-Type": "text/csv" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(response);
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("Upload failed.");
|
||||
}
|
||||
console.log("File uploaded successfully");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function generatePresignedUrl({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey,
|
||||
}: {
|
||||
userId: string;
|
||||
portfolioId: string;
|
||||
fileKey: string;
|
||||
}) {
|
||||
// fileKey is a location in S3 where we want to upload the file
|
||||
const response = await fetch("/api/upload/csv", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to generate presigned url");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
data.fileKey = fileKey;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function generateS3Keys(userId: string, portfolioId: string) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.-]/g, "");
|
||||
const assetListFileKey = `${userId}/${portfolioId}/${timestamp}/asset_list.csv`;
|
||||
const valuationDataFileKey = `${userId}/${portfolioId}/${timestamp}/valuation_data.csv`;
|
||||
return { assetListFileKey, valuationDataFileKey };
|
||||
}
|
||||
|
||||
type GenericObject = Record<string, any>;
|
||||
|
||||
const convertToCSV = <T extends Record<string, any>>(data: T[]): string => {
|
||||
if (data.length === 0) return "";
|
||||
|
||||
const headers = Object.keys(data[0]) as (keyof T)[];
|
||||
|
||||
const escape = (value: any): string => {
|
||||
if (value == null) return "";
|
||||
|
||||
const str = String(value);
|
||||
|
||||
// Check if field contains special characters
|
||||
if (/[",\n]/.test(str)) {
|
||||
// Escape double quotes and wrap in quotes
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
return str;
|
||||
};
|
||||
|
||||
const rows = data.map((row) =>
|
||||
headers.map((header) => escape(row[header])).join(",")
|
||||
);
|
||||
|
||||
return [headers.join(","), ...rows].join("\n");
|
||||
};
|
||||
|
||||
function useCreateRemoteAssessment({
|
||||
portfolioId,
|
||||
uprn,
|
||||
addressLineOne,
|
||||
postcode,
|
||||
valuation,
|
||||
propertyType,
|
||||
builtForm,
|
||||
measures,
|
||||
scenarioId,
|
||||
}: {
|
||||
portfolioId: string;
|
||||
uprn: number | undefined | null;
|
||||
addressLineOne: string;
|
||||
postcode: string;
|
||||
valuation: number | undefined | null;
|
||||
measures: (typeof measuresList)[number][];
|
||||
propertyType?: string | null;
|
||||
builtForm?: string | null;
|
||||
scenarioId?: string | null;
|
||||
}) {
|
||||
// 1) We want to upload the asset data. To do this, we format the asset data, generate a presigned URL, and upload the data to S3.
|
||||
// 2) We then want to upload valuation data. To do this, we format the valuation data, generate a presigned URL, and upload the data to S3.
|
||||
// 3) Trigger the engine!!!! This is an api at /api/plan/trigger with our body that we looked at in Miro
|
||||
|
||||
// Set up the mutation with react-query, to generate a presigned URL
|
||||
|
||||
const session = useSession();
|
||||
const userId = String(session.data?.user.dbId);
|
||||
|
||||
if (uprn === undefined || valuation === undefined) {
|
||||
throw new Error("UPRN and valuation must be provided");
|
||||
}
|
||||
|
||||
const { assetListFileKey, valuationDataFileKey } = useMemo(
|
||||
() => generateS3Keys(userId, portfolioId),
|
||||
[userId, portfolioId]
|
||||
);
|
||||
|
||||
const {
|
||||
mutate: mutateUploadFile,
|
||||
isLoading: uploadFileIsLoading,
|
||||
isError: uploadFileIsError,
|
||||
} = useMutation(uploadCsvToS3, {
|
||||
onSuccess: (data) => {
|
||||
// Callback for successful mutation
|
||||
console.log("Files uploaded successfully");
|
||||
// Trigger the engine here if needed
|
||||
},
|
||||
onError: (error) => {
|
||||
// Callback for failed mutation
|
||||
console.error("Error uploading files:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: mutatePresignedUrl,
|
||||
isLoading: presignedUrlIsLoading,
|
||||
isError: presignedUrlIsError,
|
||||
} = useMutation(generatePresignedUrl, {
|
||||
onSuccess: (data) => {
|
||||
// console.log(data.url);
|
||||
// // On success, upload to that URL!!!!
|
||||
|
||||
let csvFile: Blob = new Blob();
|
||||
|
||||
if (data.fileKey === assetListFileKey) {
|
||||
const assetList = [
|
||||
{
|
||||
uprn: uprn,
|
||||
address: addressLineOne,
|
||||
postcode: postcode,
|
||||
property_type: propertyType,
|
||||
built_form: builtForm,
|
||||
},
|
||||
];
|
||||
|
||||
csvFile = new Blob([convertToCSV(assetList)], {
|
||||
type: "text/csv",
|
||||
});
|
||||
} else if (data.fileKey === valuationDataFileKey) {
|
||||
const valuationData = [
|
||||
{
|
||||
uprn: uprn,
|
||||
valuation: valuation,
|
||||
},
|
||||
];
|
||||
|
||||
csvFile = new Blob([convertToCSV(valuationData)], {
|
||||
type: "text/csv",
|
||||
});
|
||||
}
|
||||
|
||||
mutateUploadFile({
|
||||
file: csvFile,
|
||||
presignedUrl: data.url,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
async function triggerEngine(data: RemoteAssessmentFormValues) {
|
||||
try {
|
||||
// Goal value should not be missing at this point
|
||||
if (data.goal === "Increasing EPC" && !data.goalValue) {
|
||||
throw new Error("Goal value is required");
|
||||
}
|
||||
|
||||
const triggerBody: EngineTriggerBody = {
|
||||
scenario_id: scenarioId === "__new__" ? null : scenarioId,
|
||||
portfolio_id: portfolioId,
|
||||
housing_type: data.housingType,
|
||||
goal: data.goal,
|
||||
// We only send goal_value if the goal is "Increasing EPC"
|
||||
goal_value: data.goalValue || null,
|
||||
trigger_file_path: assetListFileKey,
|
||||
already_installed_file_path: "",
|
||||
patches_file_path: "",
|
||||
non_invasive_recommendations_file_path: "",
|
||||
valuation_file_path: valuationDataFileKey,
|
||||
scenario_name: data.scenario,
|
||||
inclusions: data.measures,
|
||||
multi_plan: true,
|
||||
// If the goal is "Increasing EPC", we don't send a budget
|
||||
budget: data.budget || null,
|
||||
event_type: "remote_assessment",
|
||||
};
|
||||
|
||||
console.log("Triggering engine with body:", triggerBody);
|
||||
|
||||
const response = await fetch("/api/plan/trigger", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(triggerBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to trigger engine");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error triggering engine:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(formData: RemoteAssessmentFormValues) {
|
||||
try {
|
||||
await Promise.all([
|
||||
mutatePresignedUrl({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey: assetListFileKey,
|
||||
}),
|
||||
mutatePresignedUrl({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey: valuationDataFileKey,
|
||||
}),
|
||||
]);
|
||||
|
||||
await triggerEngine(formData);
|
||||
} catch (error) {
|
||||
console.error("Error in submission process:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleSubmit,
|
||||
triggerEngine,
|
||||
mutateUploadFile,
|
||||
presignedUrlIsLoading,
|
||||
presignedUrlIsError,
|
||||
uploadFileIsLoading,
|
||||
uploadFileIsError,
|
||||
};
|
||||
}
|
||||
|
||||
export default function RemoteAssessmentModal({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
portfolioId,
|
||||
scenarios,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
portfolioId: string;
|
||||
scenarios: ScenarioSelect[];
|
||||
}) {
|
||||
const NEW_SENTINEL = "__new__";
|
||||
const [selectedScenario, setSelectedScenario] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const [showMeasures, setShowMeasures] = useState(false);
|
||||
|
||||
const scenarioOptions: Option[] = useMemo(
|
||||
() => [
|
||||
...scenarios.map((s) => ({
|
||||
label: s.name || "",
|
||||
value: String(s.id) || "",
|
||||
disabled: false,
|
||||
})),
|
||||
],
|
||||
[scenarios]
|
||||
);
|
||||
|
||||
const form = useForm<RemoteAssessmentFormValues>({
|
||||
resolver: zodResolver(RemoteAssessmentFormSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
scenario: "",
|
||||
housingType: "",
|
||||
goal: "",
|
||||
goalValue: "",
|
||||
budget: undefined,
|
||||
addressLineOne: "",
|
||||
postcode: "",
|
||||
uprn: undefined,
|
||||
valuation: undefined,
|
||||
propertyType: null,
|
||||
builtForm: null,
|
||||
measures: measuresList,
|
||||
},
|
||||
});
|
||||
const { reset, setValue, formState } = form;
|
||||
const { isValid, isSubmitting } = formState;
|
||||
|
||||
const measures = form.watch("measures");
|
||||
const goal = form.watch("goal");
|
||||
|
||||
const {
|
||||
handleSubmit: triggerAssessment,
|
||||
presignedUrlIsLoading,
|
||||
presignedUrlIsError,
|
||||
} = useCreateRemoteAssessment({
|
||||
portfolioId,
|
||||
uprn: form.watch("uprn") ?? null,
|
||||
addressLineOne: form.watch("addressLineOne"),
|
||||
postcode: form.watch("postcode"),
|
||||
valuation: form.watch("valuation") ?? null,
|
||||
propertyType: form.watch("propertyType"),
|
||||
builtForm: form.watch("builtForm"),
|
||||
measures: measures,
|
||||
scenarioId: selectedScenario,
|
||||
});
|
||||
|
||||
const onSelectScenario = (opt: Option) => {
|
||||
setSelectedScenario(opt.value);
|
||||
if (opt.value === NEW_SENTINEL) {
|
||||
reset({
|
||||
...form.getValues(),
|
||||
scenario: "",
|
||||
housingType: "",
|
||||
goal: "",
|
||||
goalValue: "",
|
||||
});
|
||||
} else {
|
||||
const picked = scenarios.find((s) => String(s.id) === opt.value);
|
||||
|
||||
if (!picked) return;
|
||||
setValue("scenario", picked.name || "");
|
||||
setValue("housingType", picked.housingType);
|
||||
setValue("goal", picked.goal);
|
||||
setValue("goalValue", picked.goalValue || "");
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = form.handleSubmit(async (data) => {
|
||||
await triggerAssessment(data);
|
||||
form.reset();
|
||||
setIsOpen(false);
|
||||
toast({ title: "Remote assessment sent" });
|
||||
});
|
||||
|
||||
return (
|
||||
<Transition show={isOpen} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="fixed inset-0 z-10 overflow-y-auto"
|
||||
onClose={() => setIsOpen(false)}
|
||||
>
|
||||
<div className="min-h-screen px-4 text-center">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<DialogBackdrop className="fixed inset-0 bg-black/25" />
|
||||
</TransitionChild>
|
||||
|
||||
{/* Spacer for centering */}
|
||||
<span
|
||||
className="inline-block h-screen align-middle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
​
|
||||
</span>
|
||||
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel className="inline-block w-full max-w-2xl p-6 my-8 overflow-visible text-left align-middle transition-all transform bg-white shadow-xl rounded-2xl">
|
||||
<DialogTitle className="text-lg font-medium">
|
||||
Remote Assessment Details
|
||||
</DialogTitle>
|
||||
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={onSubmit} className="space-y-6 mt-4">
|
||||
{/* Scenario selector */}
|
||||
<FormItem>
|
||||
<FormLabel>Select scenario</FormLabel>
|
||||
<FormControl>
|
||||
<SelectScenarioDropdown
|
||||
scenarios={scenarioOptions}
|
||||
selectedValue={selectedScenario}
|
||||
onSelect={onSelectScenario}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
|
||||
{selectedScenario !== null && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Scenario Name */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scenario"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">
|
||||
Scenario Name
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={selectedScenario !== NEW_SENTINEL}
|
||||
placeholder="Scenario name"
|
||||
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Housing Type */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="housingType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">
|
||||
Housing Type
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{selectedScenario === NEW_SENTINEL ? (
|
||||
<SelectDropdown
|
||||
options={selecthousingTypeOptions}
|
||||
selectedOption={field.value}
|
||||
onSelectOption={(o) =>
|
||||
field.onChange(o.value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input value={field.value} disabled />
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Goal */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="goal"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">
|
||||
Goal
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{selectedScenario === NEW_SENTINEL ? (
|
||||
<SelectDropdown
|
||||
options={selectGoalOptions}
|
||||
selectedOption={field.value}
|
||||
onSelectOption={(o) =>
|
||||
field.onChange(o.value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input value={field.value} disabled />
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{goal && (
|
||||
<>
|
||||
{goal === "Increasing EPC" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="goalValue"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">
|
||||
Target EPC Rating
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{selectedScenario === NEW_SENTINEL ? (
|
||||
<SelectDropdown
|
||||
options={goalValueOptions}
|
||||
selectedOption={field.value || ""}
|
||||
onSelectOption={(opt) =>
|
||||
field.onChange(opt.value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input value={field.value} disabled />
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ✅ Budget shows for all goals but is only mandatory when goal != Increasing EPC */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="budget"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">
|
||||
{/* We mark budget as (optional) when the goal is increasing EPC*/}
|
||||
Budget (£){" "}
|
||||
{goal === "Increasing EPC" && (
|
||||
<span className="text-sm text-gray-500">
|
||||
(optional)
|
||||
</span>
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter budget"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="addressLineOne"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter address"
|
||||
{...field}
|
||||
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="postcode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">
|
||||
Postcode
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter postcode"
|
||||
{...field}
|
||||
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="uprn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">UPRN</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter UPRN"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="valuation"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-gray-800">
|
||||
Valuation
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
The valuation can be found at{" "}
|
||||
<a
|
||||
href={`https://www.zoopla.co.uk/property/uprn/${form.watch(
|
||||
"uprn"
|
||||
)}/`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
zoopla property page
|
||||
</a>
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter valuation"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-brandbrown" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-gray-600">
|
||||
<strong>Optional:</strong> Property Type and Built Form
|
||||
are only required if no EPC is available.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="propertyType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Property Type</FormLabel>
|
||||
<FormControl>
|
||||
<SelectDropdown
|
||||
options={propertyTypeOptions}
|
||||
selectedOption={field.value || ""}
|
||||
onSelectOption={(o) => field.onChange(o.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="builtForm"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Built Form</FormLabel>
|
||||
<FormControl>
|
||||
<SelectDropdown
|
||||
options={builtFormOptions}
|
||||
selectedOption={field.value || ""}
|
||||
onSelectOption={(o) => field.onChange(o.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Measures Section */}
|
||||
<div className="border-t pt-4 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMeasures(!showMeasures)}
|
||||
className="flex items-center justify-between w-full text-sm font-medium"
|
||||
>
|
||||
<span>Measures</span>
|
||||
<span>{showMeasures ? "−" : "+"}</span>
|
||||
</button>
|
||||
{showMeasures && <MeasuresCheckboxes form={form} />}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!isValid || presignedUrlIsLoading || isSubmitting
|
||||
}
|
||||
>
|
||||
{isSubmitting || presignedUrlIsLoading
|
||||
? "Submitting…"
|
||||
: "Submit"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{presignedUrlIsError && (
|
||||
<p className="text-red-500">Error uploading files</p>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
function setIsOpen(arg0: boolean) {
|
||||
throw new Error("Function not implemented.");
|
||||
}
|
||||
|
|
@ -1,7 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect, use, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, use } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
Home,
|
||||
ArrowLeft,
|
||||
Database,
|
||||
Activity,
|
||||
Lightbulb,
|
||||
} from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
export default function LoadingPage(props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
|
|
@ -9,14 +18,29 @@ export default function LoadingPage(props: {
|
|||
const params = use(props.params);
|
||||
const portfolioId = params.slug;
|
||||
const router = useRouter();
|
||||
const [countdown, setCountdown] = useState(10); // Initialize countdown state to 10 seconds
|
||||
const [countdown, setCountdown] = useState(10);
|
||||
const [stageIndex, setStageIndex] = useState(0);
|
||||
|
||||
const stages = [
|
||||
{
|
||||
icon: <Database className="w-14 h-14 text-white/90" />,
|
||||
title: "Gathering Data",
|
||||
text: "Collecting EPC, property, and mapping data from trusted sources.",
|
||||
},
|
||||
{
|
||||
icon: <Activity className="w-14 h-14 text-white/90" />,
|
||||
title: "Analysing Models",
|
||||
text: "Running retrofit simulations to identify the most effective measures.",
|
||||
},
|
||||
{
|
||||
icon: <Lightbulb className="w-14 h-14 text-white/90" />,
|
||||
title: "Generating Plan",
|
||||
text: "Assembling your detailed retrofit plan and funding summary.",
|
||||
},
|
||||
];
|
||||
|
||||
const handleBackToPortfolio = useCallback(() => {
|
||||
if (portfolioId) {
|
||||
router.push(`/portfolio/${portfolioId}`);
|
||||
} else {
|
||||
router.push(`/home`);
|
||||
}
|
||||
router.push(portfolioId ? `/portfolio/${portfolioId}` : "/home");
|
||||
}, [portfolioId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -24,51 +48,136 @@ export default function LoadingPage(props: {
|
|||
handleBackToPortfolio();
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => prev - 1);
|
||||
}, 1000);
|
||||
|
||||
const timer = setInterval(() => setCountdown((prev) => prev - 1), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [countdown, handleBackToPortfolio]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setStageIndex((prev) => (prev + 1) % stages.length);
|
||||
}, 4000);
|
||||
return () => clearInterval(timer);
|
||||
}, [stages.length]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-start min-h-screen text-center p-4 pt-32">
|
||||
<div className="bg-gray-100 p-6 rounded shadow flex flex-col items-center justify-cente">
|
||||
<h1 className="text-2xl font-semibold mb-2 text-slate-700 ">
|
||||
We're building your portfolio plan
|
||||
</h1>
|
||||
<div className="text-md mb-4 text-slate-600">
|
||||
This could take a few minutes. Thank you for your patience.
|
||||
</div>
|
||||
<div className="text-md mb-4 text-slate-600">
|
||||
Click on 'Go back to portfolio'.
|
||||
</div>
|
||||
<div className="text-sm mb-4 text-slate-600">
|
||||
We'll redirect you automatically in {countdown} seconds...
|
||||
</div>
|
||||
<div className="relative flex flex-col md:flex-row min-h-screen bg-gray-50 overflow-hidden">
|
||||
{/* LEFT PANEL – Centered Blueprint */}
|
||||
<div className="hidden md:flex w-1/2 relative items-center justify-center overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: "url('/images/Alexandra-Road-Park.webp')" }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-brandblue/95 to-midblue/85" />
|
||||
|
||||
{/* Animated grid overlay */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-8 h-8 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
className="absolute inset-0 w-full h-full opacity-20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 800 600"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
<g stroke="white" strokeWidth="0.4">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<line
|
||||
key={`v-${i}`}
|
||||
x1={(i + 1) * 40}
|
||||
y1="0"
|
||||
x2={(i + 1) * 40}
|
||||
y2="600"
|
||||
className="animate-[pulse_6s_ease-in-out_infinite]"
|
||||
/>
|
||||
))}
|
||||
{[...Array(15)].map((_, i) => (
|
||||
<line
|
||||
key={`h-${i}`}
|
||||
y1={(i + 1) * 40}
|
||||
x1="0"
|
||||
y2={(i + 1) * 40}
|
||||
x2="800"
|
||||
className="animate-[pulse_8s_ease-in-out_infinite]"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
<button
|
||||
onClick={handleBackToPortfolio}
|
||||
className="mt-5 px-4 py-2 bg-brandblue text-white rounded hover:bg-hoverblue"
|
||||
|
||||
{/* Centered analysis stage */}
|
||||
<div className="relative z-10 flex flex-col items-center justify-center text-center text-white px-8">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={stages[stageIndex].title}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -30 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
<div className="mb-5">{stages[stageIndex].icon}</div>
|
||||
<h2 className="text-4xl font-bold mb-3">
|
||||
{stages[stageIndex].title}
|
||||
</h2>
|
||||
<p className="text-lg text-white/80 max-w-sm leading-relaxed">
|
||||
{stages[stageIndex].text}
|
||||
</p>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT PANEL – Glass Card */}
|
||||
<div className="flex flex-1 flex-col justify-center items-center p-8 bg-gradient-to-b from-white to-sky-50 relative">
|
||||
<div className="absolute top-0 left-0 w-64 h-64 bg-brandbrown/10 rounded-full blur-3xl -translate-x-24 -translate-y-24 animate-pulse" />
|
||||
<div className="absolute bottom-0 right-0 w-80 h-80 bg-brandblue/10 rounded-full blur-3xl translate-x-20 translate-y-20 animate-[pulse_4s_ease-in-out_infinite]" />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="relative z-10 bg-white/80 backdrop-blur-md border border-gray-100 shadow-2xl rounded-3xl px-10 py-12 text-center max-w-lg"
|
||||
>
|
||||
Go back to portfolio
|
||||
</button>
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<Loader2 className="w-12 h-12 text-brandblue animate-spin" />
|
||||
<Home className="absolute inset-0 w-5 h-5 text-brandbrown m-auto" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-semibold text-brandblue mb-3">
|
||||
Building your retrofit plan
|
||||
</h2>
|
||||
<p className="text-slate-600 mb-3 text-base">
|
||||
Domna IQ is analysing your data and generating your plan summary.
|
||||
</p>
|
||||
|
||||
<div className="relative w-full h-2 bg-gray-200 rounded-full overflow-hidden mb-6">
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 h-full bg-brandbrown"
|
||||
animate={{ width: `${((10 - countdown) / 10) * 100}%` }}
|
||||
transition={{ ease: "easeInOut", duration: 1 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-slate-500 text-sm mb-6">
|
||||
Redirecting in{" "}
|
||||
<span className="font-semibold text-brandbrown">{countdown}</span>{" "}
|
||||
seconds...
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleBackToPortfolio}
|
||||
className="mt-3 px-5 py-3 bg-brandblue text-white rounded-xl hover:bg-hoverblue flex items-center gap-2 mx-auto transition-all shadow-sm"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
Go back to portfolio
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="relative z-10 mt-8 text-sm text-gray-500"
|
||||
>
|
||||
Your assessment is running securely in the background.
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
191
src/app/portfolio/[slug]/remote-assessment/AddressSearch.tsx
Normal file
191
src/app/portfolio/[slug]/remote-assessment/AddressSearch.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import { Card } from "@/app/shadcn_components/ui/card";
|
||||
import { Pencil } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from "@/app/shadcn_components/ui/select";
|
||||
import usePostcodeLookup from "./usePostcodeLookup";
|
||||
|
||||
interface AddressItem {
|
||||
uprn: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export default function AddressSearch({
|
||||
onAddressSelect,
|
||||
onPostcodeSelect,
|
||||
postcode,
|
||||
}: {
|
||||
onAddressSelect: (address: AddressItem | null) => void; // ✅ Fix type
|
||||
onPostcodeSelect: (postcode: string) => void;
|
||||
postcode: string;
|
||||
}) {
|
||||
const [addresses, setAddresses] = useState<AddressItem[]>([]);
|
||||
const [selectedAddress, setSelectedAddress] = useState<AddressItem | null>(
|
||||
null
|
||||
);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [triggerSearch, setTriggerSearch] = useState(false);
|
||||
const [loadingAddresses, setLoadingAddresses] = useState(false);
|
||||
const [addressError, setAddressError] = useState<string | null>(null);
|
||||
|
||||
const { data, isFetching, refetch } = usePostcodeLookup(
|
||||
postcode,
|
||||
triggerSearch
|
||||
);
|
||||
|
||||
async function handleSearch() {
|
||||
if (!postcode.trim()) return;
|
||||
setTriggerSearch(true);
|
||||
const validation = await refetch();
|
||||
setTriggerSearch(false);
|
||||
|
||||
if (!validation.data || validation.data.status !== 200) return;
|
||||
|
||||
const validatedPostcode =
|
||||
validation.data.result?.postcode?.toUpperCase() ?? postcode.toUpperCase();
|
||||
|
||||
// Use the validated postcode for fetching addresses
|
||||
onPostcodeSelect(validatedPostcode);
|
||||
|
||||
setLoadingAddresses(true);
|
||||
setAddressError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/postcode/${encodeURIComponent(validatedPostcode)}/addresses`
|
||||
);
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setAddressError(json.error || "Unable to retrieve addresses");
|
||||
setShowDropdown(false);
|
||||
} else if (json.results?.length) {
|
||||
const mapped = json.results.map((r: any) => ({
|
||||
address: r.address,
|
||||
uprn: r.uprn,
|
||||
propertyType: r.propertyType,
|
||||
builtForm: r.builtForm,
|
||||
}));
|
||||
setAddresses(mapped);
|
||||
setShowDropdown(true);
|
||||
} else {
|
||||
setAddressError("No addresses found for this postcode");
|
||||
}
|
||||
} catch {
|
||||
setAddressError("There was an issue contacting the address service.");
|
||||
} finally {
|
||||
setLoadingAddresses(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectAddress(value: string) {
|
||||
const selected = addresses.find((a) => a.address === value) || null;
|
||||
setSelectedAddress(selected);
|
||||
setShowDropdown(false);
|
||||
onAddressSelect(selected);
|
||||
}
|
||||
|
||||
function handleChangeAddress() {
|
||||
setSelectedAddress(null);
|
||||
setShowDropdown(true);
|
||||
onAddressSelect(null);
|
||||
}
|
||||
|
||||
const showInvalid = data && data.status === 404;
|
||||
const showServerError = data && data.status === 500;
|
||||
const isLoading = isFetching || loadingAddresses;
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
{!selectedAddress && (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Input
|
||||
placeholder="Enter postcode"
|
||||
value={postcode}
|
||||
onChange={(e) => onPostcodeSelect(e.target.value.toUpperCase())}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
disabled={isLoading || !postcode}
|
||||
className="bg-brandbrown text-white"
|
||||
>
|
||||
{isLoading ? "Searching..." : "Search"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation and errors */}
|
||||
{showInvalid && (
|
||||
<p className="text-sm text-red-600 mb-3">
|
||||
Invalid postcode — please check and try again.
|
||||
</p>
|
||||
)}
|
||||
{showServerError && (
|
||||
<p className="text-sm text-orange-600 mb-3">
|
||||
The postcode service is currently unavailable. Please try again later.
|
||||
</p>
|
||||
)}
|
||||
{addressError && (
|
||||
<p className="text-sm text-orange-600 mb-3">{addressError}</p>
|
||||
)}
|
||||
|
||||
{/* Address dropdown */}
|
||||
{showDropdown && addresses.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<label className="block text-sm text-gray-700 mb-2">
|
||||
Select your address
|
||||
</label>
|
||||
<Select
|
||||
onValueChange={handleSelectAddress}
|
||||
value={selectedAddress?.address || undefined} // ✅ fix value binding
|
||||
>
|
||||
<SelectTrigger className="w-full border-gray-300">
|
||||
<SelectValue placeholder="Choose an address" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{addresses.map((a) => (
|
||||
<SelectItem key={a.uprn} value={a.address}>
|
||||
{a.address}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected address display */}
|
||||
{selectedAddress && !showDropdown && (
|
||||
<div className="bg-gray-100 border rounded-xl p-6 mt-4 flex flex-col justify-between min-h-[140px]">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-brandblue mb-2">
|
||||
Selected Address
|
||||
</h3>
|
||||
<p className="text-gray-700 text-sm break-words">
|
||||
{selectedAddress.address}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleChangeAddress}
|
||||
className="flex gap-1 text-brandbrown border-brandbrown"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import AddressSearch from "./AddressSearch";
|
||||
import ScenarioSetup from "./ScenarioSetup";
|
||||
import { ScenarioSelect, measuresList } from "@/app/db/schema/recommendations";
|
||||
import useCreateRemoteAssessment from "./useCreateRemoteAssessment";
|
||||
import { RemoteAssessmentFormValues } from "@/app/portfolio/[slug]/components/FormSchema";
|
||||
import BackToPortfolioButton from "@/app/components/building-passport/BackToPortfolioButton";
|
||||
import { MapPin, ClipboardCheck, Zap } from "lucide-react";
|
||||
|
||||
export default function RemoteAssessmentClient({
|
||||
portfolioId,
|
||||
scenarios,
|
||||
}: {
|
||||
portfolioId: string;
|
||||
scenarios: ScenarioSelect[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [selectedAddress, setSelectedAddress] = useState<{
|
||||
address: string;
|
||||
uprn: string;
|
||||
propertyType?: string;
|
||||
builtForm?: string;
|
||||
} | null>(null);
|
||||
const [selectedPostcode, setSelectedPostcode] = useState<string>("");
|
||||
|
||||
const { handleSubmit: submitAssessment, isUploading } =
|
||||
useCreateRemoteAssessment({
|
||||
portfolioId,
|
||||
uprn: selectedAddress?.uprn ? parseInt(selectedAddress.uprn) : null,
|
||||
addressLineOne: selectedAddress?.address || "",
|
||||
postcode: selectedPostcode,
|
||||
valuation: null,
|
||||
propertyType: selectedAddress?.propertyType || null,
|
||||
builtForm: selectedAddress?.builtForm || null,
|
||||
measures: measuresList,
|
||||
});
|
||||
|
||||
async function onSubmitRemoteAssessment(values: RemoteAssessmentFormValues) {
|
||||
await submitAssessment(values);
|
||||
router.push(`/portfolio/${portfolioId}/plan-loading`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-b from-gray-50 to-white">
|
||||
{/* --- HERO / EXPLANATION SECTION --- */}
|
||||
<div className="relative bg-gradient-to-r from-brandblue to-midblue text-white overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center opacity-20"
|
||||
style={{
|
||||
backgroundImage: "url('/images/energy-analysis-placeholder.webp')",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto px-8 py-8 md:py-10 flex flex-col gap-6">
|
||||
{/* Title & back button pinned to top */}
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<BackToPortfolioButton portfolioId={portfolioId} />
|
||||
<h1 className="text-3xl md:text-4xl font-bold">
|
||||
Remote Assessment
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Hero text split to use horizontal space better */}
|
||||
<div className="flex flex-col md:flex-row justify-between gap-10">
|
||||
<p className="text-lg md:text-xl text-white/90 leading-relaxed max-w-2xl">
|
||||
Domna IQ analyses your property data, models retrofit options, and
|
||||
estimates potential funding — all without an on-site survey.
|
||||
</p>
|
||||
<p className="text-sm text-white/70 max-w-md md:text-right">
|
||||
Start by selecting your property, then choose retrofit goals and
|
||||
configurations. Our model will generate your baseline and plan.
|
||||
This isn`'`t a replacement for an on-site survey but a
|
||||
powerful first step.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="flex gap-6 mt-6 text-sm text-white/80 justify-start md:justify-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5" /> Find Address
|
||||
</div>
|
||||
<div className="w-8 h-[1px] bg-white/50" />
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardCheck className="w-5 h-5" /> Configure Scenario
|
||||
</div>
|
||||
<div className="w-8 h-[1px] bg-white/50" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5" /> Generate Plan
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional subtle fade transition into workspace */}
|
||||
<div className="absolute bottom-0 left-0 w-full h-2 bg-gradient-to-r from-brandblue/30 to-midblue/30 blur-sm"></div>
|
||||
</div>
|
||||
|
||||
{/* --- TWO-COLUMN WORKSPACE --- */}
|
||||
<div className="flex-1 max-w-7xl mx-auto w-full px-8 py-8">
|
||||
<div className="grid md:grid-cols-2 gap-6 items-stretch auto-rows-fr">
|
||||
{/* LEFT: Address Search */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="flex flex-col bg-white border border-gray-100 shadow-lg rounded-2xl p-8"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-brandbrown">
|
||||
Step 1: Find your property
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">Address lookup</p>
|
||||
</div>
|
||||
|
||||
<AddressSearch
|
||||
onAddressSelect={(addr) => setSelectedAddress(addr)}
|
||||
onPostcodeSelect={setSelectedPostcode}
|
||||
postcode={selectedPostcode}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* RIGHT: Scenario Setup */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1, duration: 0.6 }}
|
||||
className={`flex flex-col bg-white border border-gray-100 shadow-lg rounded-2xl p-8 transition-opacity duration-300 ${
|
||||
selectedAddress ? "opacity-100" : "opacity-40 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-brandbrown">
|
||||
Step 2: Configure scenario
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500">Your model setup</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow flex flex-col">
|
||||
<ScenarioSetup
|
||||
portfolioId={portfolioId}
|
||||
scenarios={scenarios}
|
||||
disabled={!selectedAddress}
|
||||
selectedAddress={selectedAddress?.address ?? ""}
|
||||
selectedPostcode={selectedPostcode}
|
||||
selectedUprn={Number(selectedAddress?.uprn) ?? null}
|
||||
selectedPropertyType={selectedAddress?.propertyType ?? null}
|
||||
selectedBuiltForm={selectedAddress?.builtForm ?? null}
|
||||
isSubmitting={isUploading}
|
||||
onSubmitRemoteAssessment={onSubmitRemoteAssessment}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- FOOTER NOTE --- */}
|
||||
<div className="pb-8 text-center text-xs text-gray-500">
|
||||
All assessments use verified EPC, OS AddressBase, and open data sources.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
335
src/app/portfolio/[slug]/remote-assessment/ScenarioSetup.tsx
Normal file
335
src/app/portfolio/[slug]/remote-assessment/ScenarioSetup.tsx
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Play } from "lucide-react";
|
||||
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from "@/app/shadcn_components/ui/form";
|
||||
|
||||
import { Card } from "@/app/shadcn_components/ui/card";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import {
|
||||
SelectScenarioDropdown,
|
||||
SelectDropdown,
|
||||
ScenarioOption,
|
||||
} from "@/app/portfolio/[slug]/components/RemoteAssessmentDropdowns";
|
||||
import MeasuresCheckboxes from "@/app/portfolio/[slug]/components/MeasuresCheckboxes";
|
||||
import { measuresList } from "@/app/db/schema/recommendations";
|
||||
import { ScenarioSelect } from "@/app/db/schema/recommendations";
|
||||
import {
|
||||
RemoteAssessmentFormSchema,
|
||||
type RemoteAssessmentFormValues,
|
||||
} from "@/app/portfolio/[slug]/components/FormSchema";
|
||||
|
||||
const housingTypeOptions = [
|
||||
{ label: "Social", value: "Social" },
|
||||
{ label: "Private", value: "Private" },
|
||||
];
|
||||
|
||||
// -------------------
|
||||
// Component
|
||||
// -------------------
|
||||
export default function ScenarioSetup({
|
||||
portfolioId,
|
||||
scenarios,
|
||||
disabled = false,
|
||||
selectedAddress,
|
||||
selectedPostcode,
|
||||
selectedUprn,
|
||||
selectedPropertyType,
|
||||
selectedBuiltForm,
|
||||
isSubmitting,
|
||||
onSubmitRemoteAssessment,
|
||||
}: {
|
||||
portfolioId: string;
|
||||
scenarios: ScenarioSelect[];
|
||||
disabled?: boolean;
|
||||
selectedAddress: string | null;
|
||||
selectedPostcode: string;
|
||||
selectedUprn: number | null;
|
||||
selectedPropertyType: string | null;
|
||||
selectedBuiltForm: string | null;
|
||||
isSubmitting: boolean;
|
||||
onSubmitRemoteAssessment: (values: RemoteAssessmentFormValues) => void;
|
||||
}) {
|
||||
const NEW_SENTINEL = "__new__";
|
||||
const [selectedScenario, setSelectedScenario] = useState<string | null>(null);
|
||||
const [showMeasures, setShowMeasures] = useState(false);
|
||||
|
||||
const form = useForm<RemoteAssessmentFormValues>({
|
||||
resolver: zodResolver(RemoteAssessmentFormSchema),
|
||||
mode: "onChange",
|
||||
defaultValues: {
|
||||
scenario: "",
|
||||
goal: "",
|
||||
goalValue: "",
|
||||
budget: undefined,
|
||||
housingType: "Social",
|
||||
addressLineOne: "",
|
||||
postcode: "",
|
||||
uprn: 1,
|
||||
measures: measuresList,
|
||||
},
|
||||
});
|
||||
|
||||
const { setValue, watch, handleSubmit, formState } = form;
|
||||
const values = watch();
|
||||
const [localSubmitting, setLocalSubmitting] = useState(false);
|
||||
|
||||
const scenarioOptions: ScenarioOption[] = useMemo(
|
||||
() =>
|
||||
scenarios.map((s) => ({
|
||||
label: s.name || "",
|
||||
value: String(s.id) || "",
|
||||
housingType: s.housingType || "",
|
||||
goal: s.goal || "",
|
||||
goalValue: s.goalValue || "",
|
||||
})),
|
||||
[scenarios]
|
||||
);
|
||||
|
||||
function handleSelect(opt: ScenarioOption) {
|
||||
setSelectedScenario(opt.value);
|
||||
|
||||
if (opt.value === NEW_SENTINEL) {
|
||||
form.reset({
|
||||
...form.getValues(),
|
||||
scenario: "",
|
||||
housingType: "",
|
||||
goal: "",
|
||||
goalValue: "",
|
||||
budget: undefined,
|
||||
measures: measuresList,
|
||||
addressLineOne: selectedAddress || "",
|
||||
postcode: selectedPostcode || "",
|
||||
uprn: selectedUprn || undefined,
|
||||
propertyType: selectedPropertyType || null,
|
||||
builtForm: selectedBuiltForm || null,
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
scenario: opt.label || "",
|
||||
housingType: opt.housingType || "",
|
||||
goal: opt.goal || "",
|
||||
goalValue: opt.goalValue || "",
|
||||
budget: undefined,
|
||||
measures: measuresList,
|
||||
addressLineOne: selectedAddress || "",
|
||||
postcode: selectedPostcode || "",
|
||||
uprn: selectedUprn || undefined,
|
||||
propertyType: selectedPropertyType || null,
|
||||
builtForm: selectedBuiltForm || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(data: RemoteAssessmentFormValues) {
|
||||
setLocalSubmitting(true);
|
||||
// Keep the button in submitting state until redirect completes
|
||||
await onSubmitRemoteAssessment(data);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`p-6 transition-all ${
|
||||
disabled
|
||||
? "opacity-50 pointer-events-none cursor-not-allowed"
|
||||
: "opacity-100 cursor-default"
|
||||
}`}
|
||||
>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-gray-800 text-sm font-medium mb-2">
|
||||
Select Scenario
|
||||
</label>
|
||||
<SelectScenarioDropdown
|
||||
selectedValue={selectedScenario}
|
||||
onSelect={handleSelect}
|
||||
scenarios={scenarioOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedScenario && (
|
||||
<>
|
||||
{/* Scenario Name + Housing Type */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scenario"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Scenario Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={selectedScenario !== NEW_SENTINEL}
|
||||
placeholder="Enter scenario name"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="housingType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Housing Type</FormLabel>
|
||||
<FormControl>
|
||||
{selectedScenario === NEW_SENTINEL ? (
|
||||
<SelectDropdown
|
||||
options={housingTypeOptions}
|
||||
selectedOption={field.value}
|
||||
onSelectOption={(opt) => field.onChange(opt.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input value={field.value} disabled />
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Goal + EPC */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="goal"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Goal</FormLabel>
|
||||
<FormControl>
|
||||
{selectedScenario === NEW_SENTINEL ? (
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{
|
||||
label: "Increasing EPC",
|
||||
value: "Increasing EPC",
|
||||
},
|
||||
{
|
||||
label: "Energy Savings",
|
||||
value: "Energy Savings",
|
||||
},
|
||||
{
|
||||
label: "Reducing CO₂ emissions",
|
||||
value: "Reducing CO2 emissions",
|
||||
},
|
||||
]}
|
||||
selectedOption={field.value}
|
||||
onSelectOption={(opt) => field.onChange(opt.value)}
|
||||
/>
|
||||
) : (
|
||||
<Input value={field.value} disabled />
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{values.goal === "Increasing EPC" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="goalValue"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Target EPC Rating</FormLabel>
|
||||
<FormControl>
|
||||
{selectedScenario === NEW_SENTINEL ? (
|
||||
<SelectDropdown
|
||||
options={[
|
||||
{ label: "C", value: "C" },
|
||||
{ label: "B", value: "B" },
|
||||
{ label: "A", value: "A" },
|
||||
]}
|
||||
selectedOption={field.value || ""}
|
||||
onSelectOption={(opt) =>
|
||||
field.onChange(opt.value)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input value={field.value || ""} disabled />
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Measures Section */}
|
||||
<div className="border-t pt-4 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMeasures(!showMeasures)}
|
||||
className="flex items-center justify-between w-full text-sm font-medium text-gray-800"
|
||||
>
|
||||
<span>Measures</span>
|
||||
<span>{showMeasures ? "−" : "+"}</span>
|
||||
</button>
|
||||
{showMeasures && <MeasuresCheckboxes form={form} />}
|
||||
</div>
|
||||
<div className="flex justify-end pt-6">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSubmitting || localSubmitting}
|
||||
onClick={() =>
|
||||
handleSubmit(onSubmit, (err) =>
|
||||
console.log("Validation failed:", err)
|
||||
)()
|
||||
}
|
||||
className="flex items-center gap-2 bg-brandblue text-white py-3 font-semibold hover:bg-hoverblue"
|
||||
>
|
||||
{isSubmitting || localSubmitting ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
||||
/>
|
||||
</svg>
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-5 h-5" />
|
||||
Run Assessment
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,19 @@
|
|||
export default function RemoteAssessmentPage() {
|
||||
import RemoteAssessmentClient from "./RemoteAssessmentClient";
|
||||
import { getPortfolioScenarios } from "@/app/portfolio/[slug]/utils";
|
||||
|
||||
export default async function RemoteAssessmentPage(props: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[] | undefined | number;
|
||||
}>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const portfolioId = params.slug;
|
||||
|
||||
// 🔹 Replace this with your real Drizzle query
|
||||
const scenarios = await getPortfolioScenarios(portfolioId);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">Remote Assessment</h1>
|
||||
<p className="mb-6">
|
||||
Welcome to the Remote Assessment page. Here you can start your remote
|
||||
assessment process.
|
||||
</p>
|
||||
{/* Additional content and components for remote assessment can be added here */}
|
||||
</div>
|
||||
<RemoteAssessmentClient portfolioId={portfolioId} scenarios={scenarios} />
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { measuresList } from "@/app/db/schema/recommendations";
|
||||
import { RemoteAssessmentFormValues } from "@/app/portfolio/[slug]/components/FormSchema";
|
||||
|
||||
interface EngineTriggerBody {
|
||||
portfolio_id: string;
|
||||
housing_type: string;
|
||||
goal: string;
|
||||
goal_value: string | null;
|
||||
trigger_file_path: string;
|
||||
already_installed_file_path: string;
|
||||
patches_file_path: string;
|
||||
non_invasive_recommendations_file_path: string;
|
||||
valuation_file_path: string;
|
||||
scenario_name: string;
|
||||
multi_plan: boolean;
|
||||
budget: number | null;
|
||||
event_type: string;
|
||||
inclusions: (typeof measuresList)[number][];
|
||||
scenario_id?: string | null;
|
||||
}
|
||||
|
||||
/* ---------- Helpers ---------- */
|
||||
|
||||
async function uploadCsvToS3({
|
||||
presignedUrl,
|
||||
file,
|
||||
}: {
|
||||
presignedUrl: string;
|
||||
file: Blob;
|
||||
}) {
|
||||
const response = await fetch(presignedUrl, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: { "Content-Type": "text/csv" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(response);
|
||||
throw new Error("Failed to upload CSV to S3");
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function generatePresignedUrl({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey,
|
||||
}: {
|
||||
userId: string;
|
||||
portfolioId: string;
|
||||
fileKey: string;
|
||||
}) {
|
||||
const response = await fetch("/api/upload/csv", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ userId, portfolioId, fileKey }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to generate presigned URL");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { ...data, fileKey };
|
||||
}
|
||||
|
||||
function generateS3Keys(userId: string, portfolioId: string) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.-]/g, "");
|
||||
return {
|
||||
assetListFileKey: `${userId}/${portfolioId}/${timestamp}/asset_list.csv`,
|
||||
valuationDataFileKey: `${userId}/${portfolioId}/${timestamp}/valuation_data.csv`,
|
||||
};
|
||||
}
|
||||
|
||||
const convertToCSV = <T extends Record<string, any>>(data: T[]): string => {
|
||||
if (data.length === 0) return "";
|
||||
const headers = Object.keys(data[0]) as (keyof T)[];
|
||||
const escape = (val: any) =>
|
||||
val == null
|
||||
? ""
|
||||
: /[",\n]/.test(String(val))
|
||||
? `"${String(val).replace(/"/g, '""')}"`
|
||||
: String(val);
|
||||
return [
|
||||
headers.join(","),
|
||||
...data.map((r) => headers.map((h) => escape(r[h])).join(",")),
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
/* ---------- Main Hook ---------- */
|
||||
|
||||
function useCreateRemoteAssessment({
|
||||
portfolioId,
|
||||
uprn,
|
||||
addressLineOne,
|
||||
postcode,
|
||||
valuation,
|
||||
propertyType,
|
||||
builtForm,
|
||||
measures,
|
||||
scenarioId,
|
||||
}: {
|
||||
portfolioId: string;
|
||||
uprn: number | undefined | null;
|
||||
addressLineOne: string;
|
||||
postcode: string;
|
||||
valuation: number | undefined | null;
|
||||
measures: (typeof measuresList)[number][];
|
||||
propertyType?: string | null;
|
||||
builtForm?: string | null;
|
||||
scenarioId?: string | null;
|
||||
}) {
|
||||
const { data: session } = useSession();
|
||||
const userId = String(session?.user.dbId);
|
||||
|
||||
if (uprn === undefined || valuation === undefined) {
|
||||
console.warn("Missing UPRN or valuation, cannot proceed");
|
||||
}
|
||||
|
||||
const { assetListFileKey, valuationDataFileKey } = useMemo(
|
||||
() => generateS3Keys(userId, portfolioId),
|
||||
[userId, portfolioId]
|
||||
);
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: uploadCsvToS3,
|
||||
});
|
||||
|
||||
const presignedMutation = useMutation({
|
||||
mutationFn: generatePresignedUrl,
|
||||
onSuccess: (data) => {
|
||||
let csvFile: Blob;
|
||||
if (data.fileKey === assetListFileKey) {
|
||||
const assetList: {
|
||||
uprn: number | null | undefined;
|
||||
address: string;
|
||||
postcode: string;
|
||||
property_type?: string;
|
||||
built_form?: string;
|
||||
}[] = [
|
||||
{
|
||||
uprn,
|
||||
address: addressLineOne,
|
||||
postcode,
|
||||
},
|
||||
];
|
||||
|
||||
// if we have property type and built form, include them. Handle typescript optionality
|
||||
if (propertyType) {
|
||||
assetList[0]["property_type"] = propertyType;
|
||||
}
|
||||
if (builtForm) {
|
||||
assetList[0]["built_form"] = builtForm;
|
||||
}
|
||||
|
||||
csvFile = new Blob([convertToCSV(assetList)], { type: "text/csv" });
|
||||
} else {
|
||||
const valuationData = [{ uprn, valuation }];
|
||||
csvFile = new Blob([convertToCSV(valuationData)], { type: "text/csv" });
|
||||
}
|
||||
|
||||
uploadMutation.mutate({ file: csvFile, presignedUrl: data.url });
|
||||
},
|
||||
});
|
||||
|
||||
async function triggerEngine(data: RemoteAssessmentFormValues) {
|
||||
const triggerBody: EngineTriggerBody = {
|
||||
scenario_id: scenarioId === "__new__" ? null : scenarioId,
|
||||
portfolio_id: portfolioId,
|
||||
housing_type: data.housingType,
|
||||
goal: data.goal,
|
||||
goal_value: data.goalValue || null,
|
||||
trigger_file_path: assetListFileKey,
|
||||
already_installed_file_path: "",
|
||||
patches_file_path: "",
|
||||
non_invasive_recommendations_file_path: "",
|
||||
valuation_file_path: valuation ? valuationDataFileKey : "", // We only pass a valution filepath if we have a valuation
|
||||
scenario_name: data.scenario,
|
||||
inclusions: data.measures,
|
||||
multi_plan: true,
|
||||
budget: data.budget || null,
|
||||
event_type: "remote_assessment",
|
||||
};
|
||||
|
||||
const response = await fetch("/api/plan/trigger", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(triggerBody),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to trigger engine");
|
||||
}
|
||||
|
||||
async function handleSubmit(formData: RemoteAssessmentFormValues) {
|
||||
await Promise.all([
|
||||
presignedMutation.mutateAsync({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey: assetListFileKey,
|
||||
}),
|
||||
presignedMutation.mutateAsync({
|
||||
userId,
|
||||
portfolioId,
|
||||
fileKey: valuationDataFileKey,
|
||||
}),
|
||||
]);
|
||||
|
||||
await triggerEngine(formData);
|
||||
}
|
||||
|
||||
return {
|
||||
handleSubmit,
|
||||
triggerEngine,
|
||||
isUploading: uploadMutation.isLoading || presignedMutation.isLoading,
|
||||
hasError: uploadMutation.isError || presignedMutation.isError,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCreateRemoteAssessment;
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export interface PostcodeResult {
|
||||
postcode: string;
|
||||
country: string;
|
||||
region: string | null;
|
||||
admin_district: string | null;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface PostcodeLookupResponse {
|
||||
status: number;
|
||||
result: PostcodeResult | null;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls your /api/postcode/:postcode endpoint.
|
||||
* Handles 404 gracefully (invalid postcode),
|
||||
* 500 gracefully (external service issue),
|
||||
* and only throws on client-side misuse (400).
|
||||
*/
|
||||
async function fetchPostcode(
|
||||
postcode: string
|
||||
): Promise<PostcodeLookupResponse> {
|
||||
const res = await fetch(`/api/postcode/${encodeURIComponent(postcode)}`);
|
||||
const data = await res.json();
|
||||
|
||||
switch (res.status) {
|
||||
case 200:
|
||||
return { status: 200, result: data.result };
|
||||
case 404:
|
||||
// Invalid postcode (user input issue)
|
||||
return { status: 404, result: null, message: "Invalid postcode" };
|
||||
case 500:
|
||||
// External API error
|
||||
return {
|
||||
status: 500,
|
||||
result: null,
|
||||
message:
|
||||
"We're having trouble reaching the postcode service. Please try again later.",
|
||||
};
|
||||
case 400:
|
||||
// Bad query from our side (should not happen in production)
|
||||
throw new Error("Postcode API query malformed (400).");
|
||||
default:
|
||||
// Unexpected case
|
||||
return {
|
||||
status: res.status,
|
||||
result: null,
|
||||
message: data.error || "Unexpected response from postcode API",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React Query hook for postcode validation and lookup
|
||||
*/
|
||||
function usePostcodeLookup(postcode: string, shouldFetch: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ["postcode-lookup", postcode],
|
||||
queryFn: () => fetchPostcode(postcode),
|
||||
enabled: shouldFetch && !!postcode,
|
||||
retry: false,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
}
|
||||
|
||||
export default usePostcodeLookup;
|
||||
|
|
@ -6,9 +6,6 @@ export async function middleware(req: NextRequest) {
|
|||
const token = await getToken({ req });
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
console.log("token", token);
|
||||
console.log("onboarded", token?.onboarded);
|
||||
|
||||
// If no session, send user to sign-in page
|
||||
if (!token) {
|
||||
return NextResponse.redirect(new URL("/", req.url));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue