From 54ccbf93dd1b7715ba275e6195b54d96286dc2b4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 21 Oct 2025 12:49:36 +0000 Subject: [PATCH] basic setup for remote assessment live --- src/app/components/portfolio/Toolbar.tsx | 7 - .../[slug]/components/FormSchema.tsx | 6 +- .../components/RemoteAssessmentModal.tsx | 948 ------------------ .../remote-assessment/AddressSearch.tsx | 9 +- .../RemoteAssessmentClient.tsx | 36 +- .../remote-assessment/RunAssessment.tsx | 18 - .../remote-assessment/ScenarioSetup.tsx | 53 +- 7 files changed, 76 insertions(+), 1001 deletions(-) delete mode 100644 src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx delete mode 100644 src/app/portfolio/[slug]/remote-assessment/RunAssessment.tsx diff --git a/src/app/components/portfolio/Toolbar.tsx b/src/app/components/portfolio/Toolbar.tsx index cc50eb1f..d3b8222a 100644 --- a/src/app/components/portfolio/Toolbar.tsx +++ b/src/app/components/portfolio/Toolbar.tsx @@ -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} /> - data.goal !== "Increasing EPC" || !!data.goalValue, { path: ["goalValue"], diff --git a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx deleted file mode 100644 index 0a11c003..00000000 --- a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx +++ /dev/null @@ -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 & { - 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; - -const convertToCSV = >(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(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({ - 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 ( - - setIsOpen(false)} - > -
- - - - - {/* Spacer for centering */} - - - - - - Remote Assessment Details - - - -
- {/* Scenario selector */} - - Select scenario - - - - - - {selectedScenario !== null && ( - <> -
- {/* Scenario Name */} - ( - - - Scenario Name - - - - - - - )} - /> - - {/* Housing Type */} - ( - - - Housing Type - - - {selectedScenario === NEW_SENTINEL ? ( - - field.onChange(o.value) - } - /> - ) : ( - - )} - - - - )} - /> -
- -
- {/* Goal */} - ( - - - Goal - - - {selectedScenario === NEW_SENTINEL ? ( - - field.onChange(o.value) - } - /> - ) : ( - - )} - - - - )} - /> - - {goal && ( - <> - {goal === "Increasing EPC" && ( - ( - - - Target EPC Rating - - - {selectedScenario === NEW_SENTINEL ? ( - - field.onChange(opt.value) - } - /> - ) : ( - - )} - - - - )} - /> - )} - - {/* ✅ Budget shows for all goals but is only mandatory when goal != Increasing EPC */} - ( - - - {/* We mark budget as (optional) when the goal is increasing EPC*/} - Budget (£){" "} - {goal === "Increasing EPC" && ( - - (optional) - - )} - - - - field.onChange( - e.target.value === "" - ? undefined - : Number(e.target.value) - ) - } - className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown" - /> - - - - )} - /> - - )} -
- - )} - - ( - - Address - - - - - - )} - /> - - ( - - - Postcode - - - - - - - )} - /> - - ( - - UPRN - - - field.onChange( - e.target.value === "" - ? undefined - : Number(e.target.value) - ) - } - className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown" - /> - - - - )} - /> - - ( - - - Valuation - - - The valuation can be found at{" "} - - zoopla property page - - - - - field.onChange( - e.target.value === "" - ? undefined - : Number(e.target.value) - ) - } - className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown" - /> - - - - )} - /> - -
-

- Optional: Property Type and Built Form - are only required if no EPC is available. -

-
- ( - - Property Type - - field.onChange(o.value)} - /> - - - - )} - /> - ( - - Built Form - - field.onChange(o.value)} - /> - - - - )} - /> -
-
- - {/* Measures Section */} -
- - {showMeasures && } -
- -
- - -
- - {presignedUrlIsError && ( -

Error uploading files

- )} - -
-
-
-
-
-
- ); -} -function setIsOpen(arg0: boolean) { - throw new Error("Function not implemented."); -} diff --git a/src/app/portfolio/[slug]/remote-assessment/AddressSearch.tsx b/src/app/portfolio/[slug]/remote-assessment/AddressSearch.tsx index d434f794..ab0f092c 100644 --- a/src/app/portfolio/[slug]/remote-assessment/AddressSearch.tsx +++ b/src/app/portfolio/[slug]/remote-assessment/AddressSearch.tsx @@ -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([]); const [selectedAddress, setSelectedAddress] = useState(null); const [loading, setLoading] = useState(false); @@ -71,7 +74,7 @@ export default function AddressSearch({ setPostcode(e.target.value.toUpperCase())} + onChange={(e) => onPostcodeSelect(e.target.value.toUpperCase())} className="text-lg" /> - - ); -} diff --git a/src/app/portfolio/[slug]/remote-assessment/ScenarioSetup.tsx b/src/app/portfolio/[slug]/remote-assessment/ScenarioSetup.tsx index 36074538..2c208d1b 100644 --- a/src/app/portfolio/[slug]/remote-assessment/ScenarioSetup.tsx +++ b/src/app/portfolio/[slug]/remote-assessment/ScenarioSetup.tsx @@ -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(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({ {showMeasures && } - -
+