mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
basic setup for remote assessment live
This commit is contained in:
parent
45c95340c8
commit
54ccbf93dd
7 changed files with 76 additions and 1001 deletions
|
|
@ -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";
|
||||
|
|
@ -103,12 +102,6 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
|
|||
setIsRemoteAssessmentOpen={setIsRemoteAssessmentOpen}
|
||||
/>
|
||||
</NavigationMenuList>
|
||||
<RemoteAssessmentModal
|
||||
isOpen={isRemoteAssessmentOpen}
|
||||
setIsOpen={setIsRemoteAssessmentOpen}
|
||||
portfolioId={portfolioId}
|
||||
scenarios={scenarios}
|
||||
/>
|
||||
<UploadCsvModal
|
||||
isOpen={modalIsOpen}
|
||||
setIsOpen={setModalIsOpen}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
|
|
@ -15,10 +15,13 @@ import {
|
|||
|
||||
export default function AddressSearch({
|
||||
onAddressSelect,
|
||||
onPostcodeSelect,
|
||||
postcode,
|
||||
}: {
|
||||
onAddressSelect?: (address: string | null) => void;
|
||||
onAddressSelect: (address: string | null) => void;
|
||||
onPostcodeSelect: (postcode: string) => void;
|
||||
postcode: string;
|
||||
}) {
|
||||
const [postcode, setPostcode] = useState("");
|
||||
const [addresses, setAddresses] = useState<string[]>([]);
|
||||
const [selectedAddress, setSelectedAddress] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -71,7 +74,7 @@ export default function AddressSearch({
|
|||
<Input
|
||||
placeholder="Enter postcode"
|
||||
value={postcode}
|
||||
onChange={(e) => setPostcode(e.target.value.toUpperCase())}
|
||||
onChange={(e) => onPostcodeSelect(e.target.value.toUpperCase())}
|
||||
className="text-lg"
|
||||
/>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@
|
|||
import { useState } from "react";
|
||||
import AddressSearch from "./AddressSearch";
|
||||
import ScenarioSetup from "./ScenarioSetup";
|
||||
import RunAssessment from "./RunAssessment";
|
||||
import { ScenarioSelect } from "@/app/db/schema/recommendations";
|
||||
import { measuresList } from "@/app/db/schema/recommendations";
|
||||
import useCreateRemoteAssessment from "./useCreateRemoteAssessment";
|
||||
import { RemoteAssessmentFormValues } from "@/app/portfolio/[slug]/components/FormSchema";
|
||||
|
||||
export default function RemoteAssessmentClient({
|
||||
portfolioId,
|
||||
|
|
@ -14,6 +16,24 @@ export default function RemoteAssessmentClient({
|
|||
scenarios: ScenarioSelect[];
|
||||
}) {
|
||||
const [selectedAddress, setSelectedAddress] = useState<string | null>(null);
|
||||
const [selectedPostcode, setSelectedPostcode] = useState<string>("");
|
||||
|
||||
const { handleSubmit: submitAssessment, isUploading } =
|
||||
useCreateRemoteAssessment({
|
||||
portfolioId,
|
||||
uprn: 1,
|
||||
addressLineOne: selectedAddress || "",
|
||||
postcode: selectedPostcode,
|
||||
valuation: null,
|
||||
propertyType: null,
|
||||
builtForm: null,
|
||||
measures: measuresList,
|
||||
});
|
||||
|
||||
async function onSubmitRemoteAssessment(values: RemoteAssessmentFormValues) {
|
||||
console.log("🚀 Submitting remote assessment:", values);
|
||||
await submitAssessment(values);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-8 space-y-12">
|
||||
|
|
@ -21,7 +41,11 @@ export default function RemoteAssessmentClient({
|
|||
Remote Assessment
|
||||
</h1>
|
||||
|
||||
<AddressSearch onAddressSelect={setSelectedAddress} />
|
||||
<AddressSearch
|
||||
onAddressSelect={setSelectedAddress}
|
||||
onPostcodeSelect={setSelectedPostcode}
|
||||
postcode={selectedPostcode}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`transition-all duration-300 ${
|
||||
|
|
@ -34,6 +58,10 @@ export default function RemoteAssessmentClient({
|
|||
portfolioId={portfolioId}
|
||||
scenarios={scenarios}
|
||||
disabled={!selectedAddress}
|
||||
selectedAddress={selectedAddress}
|
||||
selectedPostcode={selectedPostcode}
|
||||
isSubmitting={isUploading}
|
||||
onSubmitRemoteAssessment={onSubmitRemoteAssessment}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -43,9 +71,7 @@ export default function RemoteAssessmentClient({
|
|||
? "opacity-100 pointer-events-auto cursor-default"
|
||||
: "opacity-50 pointer-events-none cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<RunAssessment />
|
||||
</div>
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Card } from "@/app/shadcn_components/ui/card";
|
||||
import { Button } from "@/app/shadcn_components/ui/button";
|
||||
import { Play } from "lucide-react";
|
||||
|
||||
export default function RunAssessment() {
|
||||
return (
|
||||
<Card className="p-6 opacity-50 pointer-events-none">
|
||||
<h2 className="text-xl font-semibold text-brandbrown mb-4">
|
||||
Step 3: Run Assessment
|
||||
</h2>
|
||||
<Button className="flex items-center gap-2 bg-brandblue text-white">
|
||||
<Play className="w-4 h-4" /> Run Assessment
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import { useState, useMemo } from "react";
|
||||
import { useForm, FormProvider } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Play } from "lucide-react";
|
||||
|
||||
import {
|
||||
FormField,
|
||||
|
|
@ -41,12 +41,18 @@ export default function ScenarioSetup({
|
|||
portfolioId,
|
||||
scenarios,
|
||||
disabled = false,
|
||||
onSubmitScenario,
|
||||
selectedAddress,
|
||||
selectedPostcode,
|
||||
isSubmitting,
|
||||
onSubmitRemoteAssessment,
|
||||
}: {
|
||||
portfolioId: string;
|
||||
scenarios: ScenarioSelect[];
|
||||
disabled?: boolean;
|
||||
onSubmitScenario?: (values: RemoteAssessmentFormValues) => void;
|
||||
selectedAddress: string | null;
|
||||
selectedPostcode: string;
|
||||
isSubmitting: boolean;
|
||||
onSubmitRemoteAssessment: (values: RemoteAssessmentFormValues) => void;
|
||||
}) {
|
||||
const NEW_SENTINEL = "__new__";
|
||||
const [selectedScenario, setSelectedScenario] = useState<string | null>(null);
|
||||
|
|
@ -63,10 +69,7 @@ export default function ScenarioSetup({
|
|||
housingType: "Social",
|
||||
addressLineOne: "",
|
||||
postcode: "",
|
||||
uprn: 0,
|
||||
valuation: 0,
|
||||
propertyType: null,
|
||||
builtForm: null,
|
||||
uprn: 1,
|
||||
measures: measuresList,
|
||||
},
|
||||
});
|
||||
|
|
@ -97,8 +100,10 @@ export default function ScenarioSetup({
|
|||
goal: "",
|
||||
goalValue: "",
|
||||
budget: undefined,
|
||||
valuation: undefined,
|
||||
measures: measuresList, // all measures preselected
|
||||
measures: measuresList,
|
||||
addressLineOne: selectedAddress || "",
|
||||
postcode: selectedPostcode || "",
|
||||
uprn: 1, // TODO: Replace with real UPRN
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
|
|
@ -107,14 +112,17 @@ export default function ScenarioSetup({
|
|||
goal: opt.goal || "",
|
||||
goalValue: opt.goalValue || "",
|
||||
budget: undefined,
|
||||
valuation: undefined,
|
||||
measures: measuresList,
|
||||
addressLineOne: selectedAddress || "",
|
||||
postcode: selectedPostcode || "",
|
||||
uprn: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit(data: RemoteAssessmentFormValues) {
|
||||
if (onSubmitScenario) onSubmitScenario(data);
|
||||
console.log("form Data", data);
|
||||
onSubmitRemoteAssessment(data);
|
||||
console.log("Submitted scenario data:", data);
|
||||
}
|
||||
|
||||
|
|
@ -268,14 +276,25 @@ export default function ScenarioSetup({
|
|||
</button>
|
||||
{showMeasures && <MeasuresCheckboxes form={form} />}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<div className="flex justify-end pt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-brandbrown text-white hover:bg-hoverblue"
|
||||
disabled={!formState.isValid}
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
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"
|
||||
>
|
||||
Save Scenario
|
||||
{isSubmitting ? (
|
||||
"Submitting..."
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-5 h-5" />
|
||||
Run Assessment
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue