From 07b6fe08ea65d8623c56733dec634701c21d7017 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 20 Jul 2025 16:58:12 +0100 Subject: [PATCH] debugging fileupload modal --- .../[slug]/components/FormSchema.tsx | 32 +- .../[slug]/components/MeasuresCheckboxes.tsx | 39 +- .../components/RemoteAssessmentModal.tsx | 15 +- .../[slug]/components/UploadCsvModal.tsx | 580 +++++++++++++----- 4 files changed, 498 insertions(+), 168 deletions(-) diff --git a/src/app/portfolio/[slug]/components/FormSchema.tsx b/src/app/portfolio/[slug]/components/FormSchema.tsx index 566e3a4a..093e5d5b 100644 --- a/src/app/portfolio/[slug]/components/FormSchema.tsx +++ b/src/app/portfolio/[slug]/components/FormSchema.tsx @@ -1,7 +1,14 @@ +// formSchemas.ts import * as z from "zod"; import { MeasureKeyEnum } from "@/app/db/schema/recommendations"; -export const formSchema = z.object({ +export const baseFormSchema = z.object({ + measures: z.array(MeasureKeyEnum).min(1, "At least one measure is required"), +}); + +export type BaseFormValues = z.infer; + +export const RemoteAssessmentFormSchema = baseFormSchema.extend({ scenario: z.string().min(1), goal: z.string().min(1), goalValue: z.string().min(1), @@ -12,7 +19,26 @@ export const formSchema = z.object({ valuation: z.number().min(1), propertyType: z.string().nullable(), builtForm: z.string().nullable(), - measures: z.array(MeasureKeyEnum).min(1, "Select at least one measure."), }); -export type FormValues = z.infer; +export type RemoteAssessmentFormValues = z.infer< + typeof RemoteAssessmentFormSchema +>; + +export const uploadCsvSchema = baseFormSchema.extend({ + scenario: z.string().min(1), + goal: z.string().min(1), + goalValue: z.string().min(1), + housingType: z.string(), + ashpCop: z.preprocess((val) => { + 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()), +}); + +export type UploadCsvFormValues = z.infer; diff --git a/src/app/portfolio/[slug]/components/MeasuresCheckboxes.tsx b/src/app/portfolio/[slug]/components/MeasuresCheckboxes.tsx index 72f9a539..5da99ca6 100644 --- a/src/app/portfolio/[slug]/components/MeasuresCheckboxes.tsx +++ b/src/app/portfolio/[slug]/components/MeasuresCheckboxes.tsx @@ -1,6 +1,6 @@ "use client"; -import { useForm } from "react-hook-form"; +import { UseFormReturn, Path } from "react-hook-form"; import { FormField, FormItem, @@ -10,22 +10,26 @@ import { measuresList, measuresDisplayLabels, } from "@/app/db/schema/recommendations"; -import { FormValues } from "./FormSchema"; +import { BaseFormValues } from "./FormSchema"; -function MeasuresCheckboxes({ +type MeasuresCheckboxesProps = { + form: UseFormReturn; + name?: Path; // Optional override for custom field key +}; + +function MeasuresCheckboxes({ form, -}: { - form: ReturnType>; -}) { - const { control } = form; - const allMeasures = measuresList; + name = "measures" as Path, +}: MeasuresCheckboxesProps) { + const { control, setValue } = form; + return ( -
+
- {/* Measures grid */}
{measuresList.map((measure) => ( ( { const checked = e.target.checked; const current = new Set(field.value ?? []); - if (checked) { - current.add(measure); - } else { - current.delete(measure); - } + checked ? current.add(measure) : current.delete(measure); field.onChange(Array.from(current)); }} className="h-4 w-4" diff --git a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx index faba50b4..297bc669 100644 --- a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx +++ b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx @@ -25,7 +25,10 @@ import { } from "./RemoteAssessmentDropdowns"; import MeasuresCheckboxes from "./MeasuresCheckboxes"; import { measuresList } from "@/app/db/schema/recommendations"; -import { formSchema, FormValues } from "./FormSchema"; +import { + RemoteAssessmentFormSchema, + RemoteAssessmentFormValues, +} from "./FormSchema"; type Option = { label: string; @@ -348,7 +351,7 @@ function useCreateRemoteAssessment({ }, }); - async function triggerEngine(data: FormValues) { + async function triggerEngine(data: RemoteAssessmentFormValues) { try { const triggerBody: EngineTriggerBody = { scenario_id: scenarioId === "__new__" ? null : scenarioId, @@ -387,7 +390,7 @@ function useCreateRemoteAssessment({ } } - async function handleSubmit(formData: FormValues) { + async function handleSubmit(formData: RemoteAssessmentFormValues) { try { await Promise.all([ mutatePresignedUrl({ @@ -447,7 +450,7 @@ export default function RemoteAssessmentModal({ ); const form = useForm({ - resolver: zodResolver(formSchema), + resolver: zodResolver(RemoteAssessmentFormSchema), mode: "onChange", defaultValues: { scenario: "", @@ -456,8 +459,8 @@ export default function RemoteAssessmentModal({ goalValue: "", addressLineOne: "", postcode: "", - uprn: 0, - valuation: 0, + uprn: null, + valuation: null, propertyType: null as string | null, builtForm: null as string | null, measures: measuresList, diff --git a/src/app/portfolio/[slug]/components/UploadCsvModal.tsx b/src/app/portfolio/[slug]/components/UploadCsvModal.tsx index 0acc8299..f0a921c8 100644 --- a/src/app/portfolio/[slug]/components/UploadCsvModal.tsx +++ b/src/app/portfolio/[slug]/components/UploadCsvModal.tsx @@ -18,8 +18,19 @@ import { FormItem, FormLabel, FormControl, + FormMessage, } from "@/app/shadcn_components/ui/form"; +import { Input } from "@/app/shadcn_components/ui/input"; import { measuresList } from "@/app/db/schema/recommendations"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + uploadCsvSchema, + UploadCsvFormValues, +} from "@/app/portfolio/[slug]/components/FormSchema"; + +const NEW_SENTINEL = "__new__"; const selecthousingTypeOptions = [ { label: "Social", value: "Social", disabled: false }, @@ -27,12 +38,12 @@ const selecthousingTypeOptions = [ ]; const selectGoalOptions = [ - { label: "Increase EPC", value: "Increase EPC", disabled: false }, - { - label: "Reduce energy consumption", - value: "Reduce energy consumption", - disabled: false, - }, + { label: "Increasing EPC", value: "Increasing EPC", disabled: false }, + // { + // label: "Reduce energy consumption", + // value: "Reduce energy consumption", + // disabled: false, + // }, ]; const goalValueOptions = [ @@ -41,26 +52,165 @@ const goalValueOptions = [ { label: "A", value: "A", disabled: false }, ]; +function generateS3Key(userId: string, portfolioId: string) { + const timestamp = new Date().toISOString().replace(/[:.-]/g, ""); + return `${userId}/${portfolioId}/${timestamp}/asset_list.csv`; +} + +async function uploadCsvToS3({ + presignedUrl, + file, +}: { + presignedUrl: string; + file: File; +}) { + const response = await fetch(presignedUrl, { + method: "PUT", + body: file, + headers: { "Content-Type": "text/csv" }, + }); + + if (!response.ok) throw new Error("Upload failed"); + 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 { url: data.url, fileKey }; +} +export function useUploadCsvPlan({ + file, + portfolioId, + goal, + goalValue, + housingType, + scenarioName, + selectedSheet, + budget, + ashpCop, + measures, + onSuccessRedirect, +}: { + file: File; + portfolioId: string; + goal: string; + goalValue: string; + budget: number | null; + ashpCop: number; + housingType: string; + scenarioName: string; + selectedSheet: string; + measures: (typeof measuresList)[number][]; + onSuccessRedirect: (path: string) => void; +}) { + const session = useSession(); + const userId = String(session.data?.user.dbId); + + const fileKey = useMemo( + () => generateS3Key(userId, portfolioId), + [userId, portfolioId] + ); + + const { mutateAsync: uploadFileToS3, isLoading: isUploadLoading } = + useMutation(uploadCsvToS3); + + const { mutateAsync: getPresignedUrl, isLoading: isGeneratingUrlLoading } = + useMutation(generatePresignedUrl); + + const handleSubmit = async () => { + try { + const { url } = await getPresignedUrl({ userId, portfolioId, fileKey }); + + await uploadFileToS3({ presignedUrl: url, file }); + + const body = { + scenario_id: scenarioName === NEW_SENTINEL ? null : scenarioName, + portfolio_id: portfolioId, + housing_type: housingType, + goal: goal, + goal_value: goalValue, + trigger_file_path: fileKey, + valuation_file_path: "", // Not used in this context + already_installed_file_path: "", + patches_file_path: "", + non_invasive_recommendations_file_path: "", + multi_plan: true, + budget: budget, + scenario_name: scenarioName, + inclusions: measures, + event_type: "remote_assessment", + sheet_name: selectedSheet, + ashp_cop: ashpCop, + }; + + const triggerRes = await fetch("/api/plan/trigger", { + method: "POST", + body: JSON.stringify(body), + }); + + if (!triggerRes.ok) throw new Error("Failed to trigger engine"); + + onSuccessRedirect(`/portfolio/${portfolioId}/plan-loading`); + } catch (err) { + console.error("Upload or trigger failed", err); + throw err; + } + }; + + return { + handleSubmit, + isUploadLoading, + isGeneratingUrlLoading, + }; +} + export default function UploadCsvModal({ isOpen = false, setIsOpen, portfolioId, scenarios, +}: { + isOpen?: boolean; + setIsOpen: (open: boolean) => void; + portfolioId: string; + scenarios: ScenarioSelect[]; }) { - const form = useForm({ + const form = useForm({ + resolver: zodResolver(uploadCsvSchema), + shouldUnregister: false, + mode: "onChange", defaultValues: { scenario: "", housingType: "", goal: "", goalValue: "", - budget: "", + budget: null, measures: measuresList, }, }); - const [sheetNames, setSheetNames] = useState([]); + const { formState } = form; + const { isValid, isSubmitting } = formState; + + const router = useRouter(); + + const [sheetNames, setSheetNames] = useState([]); const [selectedSheet, setSelectedSheet] = useState(""); - const [csvFile, setCsvFile] = useState(null); - const [selectedScenario, setSelectedScenario] = useState(null); + const [csvFile, setCsvFile] = useState(null); + const [selectedScenario, setSelectedScenario] = useState(null); const [showMeasures, setShowMeasures] = useState(false); const scenarioOptions = useMemo( @@ -74,7 +224,7 @@ export default function UploadCsvModal({ ); const { mutate: validateFile, isLoading: isValidating } = useMutation( - async (file) => { + async (file: File) => { const formData = new FormData(); formData.append("file", file); const response = await fetch("/api/upload/validate", { @@ -101,15 +251,16 @@ export default function UploadCsvModal({ } ); - const onSelectScenario = (opt) => { + const onSelectScenario = (opt: { value: string }) => { setSelectedScenario(opt.value); - if (opt.value === "__new__") { + if (opt.value === NEW_SENTINEL) { form.reset({ ...form.getValues(), scenario: "", housingType: "", goal: "", goalValue: "", + ashpCop: 2.8, // Default COP value - eventually to come from db }); } else { const picked = scenarios.find((s) => String(s.id) === opt.value); @@ -121,10 +272,33 @@ export default function UploadCsvModal({ } }; - const onSubmit = form.handleSubmit((data) => { - console.log({ ...data, csvFile, selectedSheet }); + const uploadCsvPlan = useUploadCsvPlan({ + file: csvFile!, + goal: form.watch("goal"), + goalValue: form.watch("goalValue"), + housingType: form.watch("housingType"), + budget: form.watch("budget") || null, + ashpCop: form.watch("ashpCop"), + scenarioName: form.watch("scenario"), + measures: form.watch("measures"), + portfolioId, + selectedSheet, + onSuccessRedirect: (path) => router.push(path), }); + const onSubmit = form.handleSubmit(async () => { + if (!csvFile || !selectedSheet) return; + await uploadCsvPlan.handleSubmit(); + form.reset(); + }); + + console.log("csvFile:", csvFile); + console.log("isValidating:", isValidating); + console.log("isValid:", isValid); + console.log("isSubmitting:", isSubmitting); + console.log("Raw form values", form.getValues()); + console.log("Zod parse result", uploadCsvSchema.safeParse(form.getValues())); + return ( -
+ + {/* File Upload */}
{ @@ -180,6 +358,8 @@ export default function UploadCsvModal({ View example CSV format
+ + {/* Sheet selection */} {sheetNames.length > 0 && (
)} -
- - Select scenario - - - - -
+ + {/* Scenario selection */} + + Select scenario + + + + + + {/* Scenario definition inputs */} {selectedScenario !== null && ( <> - ( - - - Scenario Name - - - - - - )} - /> - - Housing type - - {selectedScenario === "__new__" ? ( - - form.setValue("housingType", opt.value) - } - /> - ) : ( - +
+ {/* Scenario Name */} + ( + + + Scenario Name + + + + + + )} - - - - Goal - - {selectedScenario === "__new__" ? ( - - form.setValue("goal", opt.value) - } - /> - ) : ( - + /> + + {/* Housing Type */} + ( + + + Housing Type + + + {selectedScenario === NEW_SENTINEL ? ( + + field.onChange(o.value) + } + /> + ) : ( + + )} + + + )} - - - {form.watch("goal") === "Increase EPC" && ( - - Choose a target EPC value - - {selectedScenario === "__new__" ? ( - - form.setValue("goalValue", opt.value) - } - /> - ) : ( - - )} - - - )} - ( - - - Budget (optional) - - - - - - )} - /> + /> +
+ +
+ {/* Goal */} + ( + + + Goal + + + {selectedScenario === NEW_SENTINEL ? ( + + field.onChange(o.value) + } + /> + ) : ( + + )} + + + + )} + /> + + {/* Goal Value */} + ( + + + Goal Value + + + {selectedScenario === NEW_SENTINEL ? ( + + field.onChange(opt.value) + } + /> + ) : ( + + )} + + + + )} + /> +
+ +
+                        {JSON.stringify(form.formState.errors, null, 2)}
+                      
+
{JSON.stringify(form.watch(), null, 2)}
+ +
+ {/* Heat Pump COP */} + ( + + + Heat Pump COP + + + + + + + )} + /> + + {/* Budget */} + ( + + + Budget (£) + + + + + + + )} + /> +
+ + {/* ( + + Heat pump COP + + + + + )} + /> */} + + {/* ( + + Budget (optional) + + + field.onChange( + e.target.value === "" + ? null + : parseFloat(e.target.value) + ) + } + /> + + + )} + /> */} )} + + {/* Measures */}
- {showMeasures && } +
+ +
-
+ +
-