debugging fileupload modal

This commit is contained in:
Khalim Conn-Kowlessar 2025-07-20 16:58:12 +01:00
parent c34969a941
commit 07b6fe08ea
4 changed files with 498 additions and 168 deletions

View file

@ -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<typeof baseFormSchema>;
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<typeof formSchema>;
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<typeof uploadCsvSchema>;

View file

@ -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<T extends BaseFormValues> = {
form: UseFormReturn<T>;
name?: Path<T>; // Optional override for custom field key
};
function MeasuresCheckboxes<T extends BaseFormValues>({
form,
}: {
form: ReturnType<typeof useForm<FormValues>>;
}) {
const { control } = form;
const allMeasures = measuresList;
name = "measures" as Path<T>,
}: MeasuresCheckboxesProps<T>) {
const { control, setValue } = form;
return (
<div className="flex flex-col gap-4 mt-4 ">
<div className="flex flex-col gap-4 mt-4">
<div className="flex justify-start gap-4">
<button
type="button"
onClick={() =>
form.setValue("measures", allMeasures, { shouldValidate: true })
setValue(name, measuresList as any, { shouldValidate: true })
}
className="text-sm text-brandbrown underline"
>
@ -33,36 +37,29 @@ function MeasuresCheckboxes({
</button>
<button
type="button"
onClick={() =>
form.setValue("measures", [], { shouldValidate: true })
}
onClick={() => setValue(name, [] as any, { shouldValidate: true })}
className="text-sm text-brandbrown underline"
>
Untick All
</button>
</div>
{/* Measures grid */}
<div className="grid grid-cols-2 gap-4">
{measuresList.map((measure) => (
<FormField
key={measure}
control={control}
name="measures"
name={name}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<input
type="checkbox"
value={measure}
checked={field.value?.includes(measure) ?? true}
checked={field.value?.includes(measure) ?? false}
onChange={(e) => {
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"

View file

@ -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,

View file

@ -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<UploadCsvFormValues>({
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<string[]>([]);
const [selectedSheet, setSelectedSheet] = useState("");
const [csvFile, setCsvFile] = useState(null);
const [selectedScenario, setSelectedScenario] = useState(null);
const [csvFile, setCsvFile] = useState<File | null>(null);
const [selectedScenario, setSelectedScenario] = useState<string | null>(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 (
<Transition appear show={isOpen} as={Fragment}>
<Dialog
@ -164,7 +338,11 @@ export default function UploadCsvModal({
Upload Property Data
</Dialog.Title>
<FormProvider {...form}>
<form onSubmit={onSubmit} className="flex flex-col gap-6 mt-6">
<form
onSubmit={onSubmit}
className="flex flex-col flex-grow gap-6 mt-6"
>
{/* File Upload */}
<div className="flex flex-col gap-2">
<InputFile
onFileSelect={(file) => {
@ -180,6 +358,8 @@ export default function UploadCsvModal({
View example CSV format
</a>
</div>
{/* Sheet selection */}
{sheetNames.length > 0 && (
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
@ -194,132 +374,241 @@ export default function UploadCsvModal({
onSelectOption={(opt) => setSelectedSheet(opt.value)}
/>
<p className="text-xs text-gray-500">
Make sure the selected tab uses the Standardised Asset
List format.
Ensure this sheet uses the Standardised Asset List
format.
</p>
</div>
)}
<div className="flex flex-col gap-2">
<FormItem>
<FormLabel>Select scenario</FormLabel>
<FormControl>
<SelectScenarioDropdown
scenarios={scenarioOptions}
selectedValue={selectedScenario}
onSelect={onSelectScenario}
/>
</FormControl>
</FormItem>
</div>
{/* Scenario selection */}
<FormItem>
<FormLabel>Select scenario</FormLabel>
<FormControl>
<SelectScenarioDropdown
scenarios={scenarioOptions}
selectedValue={selectedScenario}
onSelect={onSelectScenario}
/>
</FormControl>
</FormItem>
{/* Scenario definition inputs */}
{selectedScenario !== null && (
<>
<FormField
control={form.control}
name="scenario"
render={({ field }) => (
<FormItem>
<FormLabel className="text-gray-800">
Scenario Name
</FormLabel>
<FormControl>
<input
{...field}
disabled={selectedScenario !== "__new__"}
placeholder="Scenario name"
className="w-full rounded-lg border border-brandbrown bg-white px-4 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brandbrown"
/>
</FormControl>
</FormItem>
)}
/>
<FormItem>
<FormLabel>Housing type</FormLabel>
<FormControl>
{selectedScenario === "__new__" ? (
<SelectDropdown
options={selecthousingTypeOptions}
selectedOption={form.watch("housingType")}
onSelectOption={(opt) =>
form.setValue("housingType", opt.value)
}
/>
) : (
<input
value={form.watch("housingType")}
disabled
className="w-full rounded-lg border border-brandbrown bg-gray-100 px-4 py-2 text-sm"
/>
<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>
)}
</FormControl>
</FormItem>
<FormItem>
<FormLabel>Goal</FormLabel>
<FormControl>
{selectedScenario === "__new__" ? (
<SelectDropdown
options={selectGoalOptions}
selectedOption={form.watch("goal")}
onSelectOption={(opt) =>
form.setValue("goal", opt.value)
}
/>
) : (
<input
value={form.watch("goal")}
disabled
className="w-full rounded-lg border border-brandbrown bg-gray-100 px-4 py-2 text-sm"
/>
/>
{/* 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>
)}
</FormControl>
</FormItem>
{form.watch("goal") === "Increase EPC" && (
<FormItem>
<FormLabel>Choose a target EPC value</FormLabel>
<FormControl>
{selectedScenario === "__new__" ? (
<SelectDropdown
options={goalValueOptions}
selectedOption={form.watch("goalValue")}
onSelectOption={(opt) =>
form.setValue("goalValue", opt.value)
}
/>
) : (
<input
value={form.watch("goalValue")}
disabled
className="w-full rounded-lg border border-brandbrown bg-gray-100 px-4 py-2 text-sm"
/>
)}
</FormControl>
</FormItem>
)}
<FormField
control={form.control}
name="budget"
render={({ field }) => (
<FormItem>
<FormLabel className="text-gray-800">
Budget (optional)
</FormLabel>
<FormControl>
<input
type="number"
placeholder="Set a budget"
{...field}
disabled={selectedScenario !== "__new__"}
className={`w-full rounded-lg border ${
selectedScenario !== "__new__"
? "bg-gray-100"
: "bg-white"
} px-4 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brandbrown`}
/>
</FormControl>
</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 Value */}
<FormField
control={form.control}
name="goalValue"
render={({ field }) => (
<FormItem>
<FormLabel className="text-gray-800">
Goal Value
</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>
)}
/>
</div>
<pre>
{JSON.stringify(form.formState.errors, null, 2)}
</pre>
<pre>{JSON.stringify(form.watch(), null, 2)}</pre>
<div className="grid grid-cols-2 gap-4">
{/* Heat Pump COP */}
<FormField
control={form.control}
name="ashpCop"
render={({ field }) => (
<FormItem>
<FormLabel className="text-gray-800">
Heat Pump COP
</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
placeholder="e.g. 2.8"
{...field}
value={
field.value === undefined ? "" : field.value
}
disabled={selectedScenario !== NEW_SENTINEL}
/>
</FormControl>
<FormMessage className="text-brandbrown" />
</FormItem>
)}
/>
{/* Budget */}
<FormField
control={form.control}
name="budget"
render={({ field }) => (
<FormItem>
<FormLabel className="text-gray-800">
Budget (£)
</FormLabel>
<FormControl>
<Input
type="number"
step="1"
placeholder="Optional"
{...field}
value={field.value ?? ""}
disabled={selectedScenario !== NEW_SENTINEL}
/>
</FormControl>
<FormMessage className="text-brandbrown" />
</FormItem>
)}
/>
</div>
{/* <FormField
control={form.control}
name="cop"
render={({ field }) => (
<FormItem>
<FormLabel>Heat pump COP</FormLabel>
<FormControl>
<input
type="number"
step="0.1"
{...field}
disabled={selectedScenario !== "__new__"}
className={`w-full rounded-lg border ${
selectedScenario !== "__new__"
? "bg-gray-100"
: "bg-white"
} border-brandbrown px-4 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brandbrown`}
/>
</FormControl>
</FormItem>
)}
/> */}
{/* <FormField
control={form.control}
name="budget"
render={({ field }) => (
<FormItem>
<FormLabel>Budget (optional)</FormLabel>
<FormControl>
<input
type="text"
inputMode="decimal"
value={field.value ?? ""}
onChange={(e) =>
field.onChange(
e.target.value === ""
? null
: parseFloat(e.target.value)
)
}
/>
</FormControl>
</FormItem>
)}
/> */}
</>
)}
{/* Measures */}
<div className="border-t pt-4 mt-6">
<button
type="button"
@ -329,9 +618,12 @@ export default function UploadCsvModal({
<span>Measures</span>
<span>{showMeasures ? "" : "+"}</span>
</button>
{showMeasures && <MeasuresCheckboxes form={form} />}
<div className={showMeasures ? "" : "hidden"}>
<MeasuresCheckboxes form={form} />
</div>
</div>
<div className="flex justify-end gap-4">
<div className="mt-auto pt-4 flex justify-end gap-4">
<Button
type="button"
variant="outline"
@ -339,8 +631,20 @@ export default function UploadCsvModal({
>
Cancel
</Button>
<Button type="submit" disabled={!csvFile}>
Submit
<Button
type="submit"
disabled={
!csvFile || isValidating || !isValid || isSubmitting
}
>
{isSubmitting ? (
<div className="flex items-center gap-2">
<span className="loader h-4 w-4 border-2 border-t-transparent border-white rounded-full animate-spin" />
Submitting...
</div>
) : (
"Submit"
)}
</Button>
</div>
</form>