Merge pull request #52 from Hestia-Homes/engine-file-upload

Engine file upload
This commit is contained in:
KhalimCK 2025-07-21 09:06:31 +01:00 committed by GitHub
commit 06f862e819
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 3804 additions and 618 deletions

View file

@ -19,6 +19,7 @@ const PresignedUrlBodySchema = z.object({
already_installed_file_path: z.string().optional(),
// optional scenario_id to link the plan to an existing scenario
scenario_id: z.string().optional().nullable(),
file_type: z.enum(["csv", "xlsx"]).optional(), // Specify the file type
});
export async function POST(request: NextRequest) {
@ -54,8 +55,6 @@ export async function POST(request: NextRequest) {
console.log("Triggering plan with body: ", validatedBody);
const url = `${process.env.FASTAPI_API_URL}/v1/plan/trigger`;
console.log("Triggering plan with url: ", url);
console.log("Triggering plan with headers: ", headers);
const response = await fetch(url, {
method: "POST",

View file

@ -10,23 +10,70 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
console.log("MADE IT HERE 0");
console.log(file, file.constructor.name);
const arrayBuffer = await file.arrayBuffer();
console.log("MADE IT HERE");
const workbook = XLSX.read(arrayBuffer, { type: "array" });
const filename = file.name.toLowerCase();
const mimeType = file.type;
const sheetNames = workbook.SheetNames;
let fileType: "csv" | "xlsx" | "unknown" = "unknown";
if (sheetNames.length > 1) {
if (
filename.endsWith(".csv") ||
mimeType === "text/csv" ||
mimeType === "application/vnd.ms-excel"
) {
fileType = "csv";
} else if (
filename.endsWith(".xlsx") ||
filename.endsWith(".xls") ||
mimeType ===
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
) {
fileType = "xlsx";
}
if (fileType === "unknown") {
return NextResponse.json(
{ error: "Multiple sheets not allowed." },
{ error: "Unsupported file type", file_type: fileType },
{ status: 400 }
);
}
// TODO: We can check if we have a standardised asset list and if so, check which tabs have that option and give the user
// the chance to select which tab they want to use
let isStandardised = false;
let sheetNames: string[] = [];
return NextResponse.json({ message: "Valid file." });
// Handle CSV
if (fileType === "csv") {
const text = await file.text();
const lines = text.split("\n").map((line) => line.split(","));
const headers = lines[0].map((h) => h.trim());
isStandardised = headers.includes("domna_property_id");
}
// Handle Excel
if (fileType === "xlsx") {
const arrayBuffer = await file.arrayBuffer();
// Only load specific sheet to reduce memory usage
const workbook = XLSX.read(arrayBuffer, {
type: "array",
sheets: ["Standardised Asset List"],
});
sheetNames = workbook.SheetNames;
const worksheet = workbook.Sheets["Standardised Asset List"];
if (worksheet) {
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
const headers = jsonData[0] as string[];
isStandardised = headers?.includes("domna_property_id");
}
}
return NextResponse.json({
message: isStandardised
? "Standardised Asset List format detected."
: "Valid file. No standardised format detected.",
isStandardised,
file_type: fileType,
...(fileType === "xlsx" && isStandardised ? { sheetNames } : {}),
});
}

View file

@ -102,6 +102,7 @@ export function Toolbar({ portfolioId, scenarios }: ToolbarProps) {
isOpen={modalIsOpen}
setIsOpen={setModalIsOpen}
portfolioId={portfolioId}
scenarios={scenarios}
/>
</NavigationMenu>
);

View file

@ -0,0 +1 @@
ALTER TABLE "scenario" ADD COLUMN "ashp_cop" real DEFAULT 2.8;

File diff suppressed because it is too large Load diff

View file

@ -708,6 +708,13 @@
"when": 1749810028140,
"tag": "0100_wakeful_doctor_doom",
"breakpoints": true
},
{
"idx": 101,
"version": "5",
"when": 1752931250899,
"tag": "0101_bent_stature",
"breakpoints": true
}
]
}

View file

@ -109,6 +109,7 @@ export const scenario = pgTable("scenario", {
housingType: housingTypeEnum("housing_type").notNull(),
goal: goalEnum("goal").notNull(),
goalValue: text("goal_value"),
ashpCop: real("ashp_cop").default(2.8), // Coefficient of Performance for Air Source Heat Pumps defaults to 2.8
triggerFilePath: text("trigger_file_path"),
alreadyInstalledFilePath: text("already_installed_file_path"),
patchesFilePath: text("patches_file_path"),

View file

@ -0,0 +1,44 @@
// formSchemas.ts
import * as z from "zod";
import { MeasureKeyEnum } from "@/app/db/schema/recommendations";
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),
housingType: z.string().min(1),
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(),
});
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

@ -28,7 +28,6 @@ export function InputFile({
return (
<div className="grid w-full max-w-sm items-center gap-1.5 text-sm font-semibold text-gray-600">
<Label htmlFor="csv-uploader">Upload your file (CSV or Excel)</Label>
<Input
id="csv-uploader"
type="file"

View file

@ -0,0 +1,79 @@
"use client";
import { UseFormReturn, Path } from "react-hook-form";
import {
FormField,
FormItem,
FormLabel,
} from "@/app/shadcn_components/ui/form";
import {
measuresList,
measuresDisplayLabels,
} from "@/app/db/schema/recommendations";
import { BaseFormValues } from "./FormSchema";
type MeasuresCheckboxesProps<T extends BaseFormValues> = {
form: UseFormReturn<T>;
name?: Path<T>; // Optional override for custom field key
};
function MeasuresCheckboxes<T extends BaseFormValues>({
form,
name = "measures" as Path<T>,
}: MeasuresCheckboxesProps<T>) {
const { control, setValue } = form;
return (
<div className="flex flex-col gap-4 mt-4">
<div className="flex justify-start gap-4">
<button
type="button"
onClick={() =>
setValue(name, measuresList as any, { shouldValidate: true })
}
className="text-sm text-brandbrown underline"
>
Select All
</button>
<button
type="button"
onClick={() => setValue(name, [] as any, { shouldValidate: true })}
className="text-sm text-brandbrown underline"
>
Untick All
</button>
</div>
<div className="grid grid-cols-2 gap-4">
{measuresList.map((measure) => (
<FormField
key={measure}
control={control}
name={name}
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<input
type="checkbox"
value={measure}
checked={field.value?.includes(measure) ?? false}
onChange={(e) => {
const checked = e.target.checked;
const current = new Set(field.value ?? []);
checked ? current.add(measure) : current.delete(measure);
field.onChange(Array.from(current));
}}
className="h-4 w-4"
/>
<FormLabel className="text-sm">
{measuresDisplayLabels[measure]}
</FormLabel>
</FormItem>
)}
/>
))}
</div>
</div>
);
}
export default MeasuresCheckboxes;

View file

@ -1,14 +1,13 @@
"use client";
import { Dialog, Transition, Menu } from "@headlessui/react";
import { Dialog, Transition } 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, useFormContext } from "react-hook-form";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import {
FormField,
FormItem,
@ -24,11 +23,12 @@ import {
SelectScenarioDropdown,
SelectDropdown,
} from "./RemoteAssessmentDropdowns";
import MeasuresCheckboxes from "./MeasuresCheckboxes";
import { measuresList } from "@/app/db/schema/recommendations";
import {
measuresList,
measuresDisplayLabels,
MeasureKeyEnum,
} from "@/app/db/schema/recommendations";
RemoteAssessmentFormSchema,
RemoteAssessmentFormValues,
} from "./FormSchema";
type Option = {
label: string;
@ -161,22 +161,6 @@ interface EngineTriggerBody {
scenario_id?: string | null;
}
const formSchema = z.object({
scenario: z.string().min(1),
goal: z.string().min(1),
goalValue: z.string().min(1),
housingType: z.string().min(1),
addressLineOne: z.string().min(1),
postcode: z.string().min(1),
uprn: z.number().min(1),
valuation: z.number().min(1),
propertyType: z.string().nullable(),
builtForm: z.string().nullable(),
measures: z.array(MeasureKeyEnum).min(1, "Select at least one measure."),
});
type FormValues = z.infer<typeof formSchema>;
async function uploadCsvToS3({
presignedUrl,
file,
@ -280,10 +264,10 @@ function useCreateRemoteAssessment({
scenarioId,
}: {
portfolioId: string;
uprn: number | null;
uprn: number | undefined | null;
addressLineOne: string;
postcode: string;
valuation: string | number | null;
valuation: number | undefined | null;
measures: (typeof measuresList)[number][];
propertyType?: string | null;
builtForm?: string | null;
@ -298,6 +282,10 @@ function useCreateRemoteAssessment({
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]
@ -367,7 +355,7 @@ function useCreateRemoteAssessment({
},
});
async function triggerEngine(data: FormValues) {
async function triggerEngine(data: RemoteAssessmentFormValues) {
try {
const triggerBody: EngineTriggerBody = {
scenario_id: scenarioId === "__new__" ? null : scenarioId,
@ -406,7 +394,7 @@ function useCreateRemoteAssessment({
}
}
async function handleSubmit(formData: FormValues) {
async function handleSubmit(formData: RemoteAssessmentFormValues) {
try {
await Promise.all([
mutatePresignedUrl({
@ -465,10 +453,8 @@ export default function RemoteAssessmentModal({
[scenarios]
);
console.log("Scenario options:", scenarioOptions);
const form = useForm({
resolver: zodResolver(formSchema),
const form = useForm<RemoteAssessmentFormValues>({
resolver: zodResolver(RemoteAssessmentFormSchema),
mode: "onChange",
defaultValues: {
scenario: "",
@ -477,10 +463,10 @@ export default function RemoteAssessmentModal({
goalValue: "",
addressLineOne: "",
postcode: "",
uprn: 0,
valuation: 0,
propertyType: null as string | null,
builtForm: null as string | null,
uprn: undefined, // Must match expected type
valuation: undefined, // Must match expected type
propertyType: null,
builtForm: null,
measures: measuresList,
},
});
@ -495,10 +481,10 @@ export default function RemoteAssessmentModal({
presignedUrlIsError,
} = useCreateRemoteAssessment({
portfolioId,
uprn: form.watch("uprn"),
uprn: form.watch("uprn") ?? null,
addressLineOne: form.watch("addressLineOne"),
postcode: form.watch("postcode"),
valuation: form.watch("valuation"),
valuation: form.watch("valuation") ?? null,
propertyType: form.watch("propertyType"),
builtForm: form.watch("builtForm"),
measures: measures,
@ -750,8 +736,13 @@ export default function RemoteAssessmentModal({
type="number"
placeholder="Enter UPRN"
{...field}
value={field.value ?? ""}
onChange={(e) =>
field.onChange(Number(e.target.value))
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value)
)
}
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
/>
@ -786,8 +777,13 @@ export default function RemoteAssessmentModal({
type="number"
placeholder="Enter valuation"
{...field}
value={field.value ?? ""}
onChange={(e) =>
field.onChange(Number(e.target.value))
field.onChange(
e.target.value === ""
? undefined
: Number(e.target.value)
)
}
className="border-brandbrown focus-visible:ring-brandbrown focus-visible:border-brandbrown"
/>
@ -888,70 +884,3 @@ export default function RemoteAssessmentModal({
function setIsOpen(arg0: boolean) {
throw new Error("Function not implemented.");
}
function MeasuresCheckboxes({
form,
}: {
form: ReturnType<typeof useForm<FormValues>>;
}) {
const { control } = form;
const allMeasures = measuresList;
return (
<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 })
}
className="text-sm text-brandbrown underline"
>
Select All
</button>
<button
type="button"
onClick={() =>
form.setValue("measures", [], { 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"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<input
type="checkbox"
value={measure}
checked={field.value?.includes(measure) ?? true}
onChange={(e) => {
const checked = e.target.checked;
const current = new Set(field.value ?? []);
if (checked) {
current.add(measure);
} else {
current.delete(measure);
}
field.onChange(Array.from(current));
}}
className="h-4 w-4"
/>
<FormLabel className="text-sm">
{measuresDisplayLabels[measure]}
</FormLabel>
</FormItem>
)}
/>
))}
</div>
</div>
);
}

View file

@ -1,195 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { useMemo } from "react";
function generateS3Key(userId: string, portfolioId: string, filename: string) {
const timestamp = new Date().toISOString().replace(/[:.-]/g, "");
const key = `${userId}/${portfolioId}/${filename}-${timestamp}.csv`;
return key;
}
interface UseCreatePlanProps {
portfolioId: string;
housingType: string;
goal: string;
goalValue: string;
file: File;
}
interface UseCreatePlanReturn {
handlePlanBuild: () => void;
isGeneratingUrlLoading: boolean;
isUploadLoading: boolean;
}
const useCreatePlan = ({
portfolioId,
housingType,
goal,
goalValue,
file,
}: UseCreatePlanProps): UseCreatePlanReturn => {
const router = useRouter();
const session = useSession();
// Since userId is a big int it gets coerced into a string
const userId = String(session.data?.user.dbId);
// Every time the component is re-rendered, a new file key will be generated. To prevent this,
// we use useMemo to only generate a new file key when the userId or portfolioId changes.
const fileKey = useMemo(
() => generateS3Key(userId, portfolioId, "portfolio_plan_properties"),
[userId, portfolioId]
);
const { mutate: mutateUploadCsv, isLoading: isUploadLoading } = useMutation(
uploadCsvToS3,
{
onSuccess: () => {
const body = JSON.stringify({
portfolio_id: portfolioId,
housing_type: housingType,
goal: goal,
goal_value: goalValue,
trigger_file_path: fileKey,
});
const response = fetch(`/api/plan/trigger`, {
method: "POST",
body: body,
});
return response;
},
onError: (error) => {
console.error(error);
},
}
);
const { mutate, isLoading: isGeneratingUrlLoading } = useMutation(
generatePresignedUrl,
{
onSuccess: (data) => {
try {
const response = mutateUploadCsv({
presignedUrl: data.url,
file: file,
});
return response;
} catch (error) {
console.error(error);
}
},
onError: (error) => {
console.error(error);
},
}
);
const handlePlanBuild = () => {
mutate({ userId: userId, portfolioId: portfolioId, fileKey: fileKey });
router.push(`/portfolio/${portfolioId}/plan-loading`);
};
return {
handlePlanBuild,
isGeneratingUrlLoading,
isUploadLoading,
};
};
async function generatePresignedUrl({
userId,
portfolioId,
fileKey,
}: {
userId: string;
portfolioId: string;
fileKey: string;
}) {
const body = JSON.stringify({
userId: userId,
portfolioId: portfolioId,
fileKey: fileKey,
});
const presignedResponse = await fetch(`/api/upload/csv`, {
method: "POST",
body: body,
});
if (!presignedResponse.ok) {
throw new Error("Network response was not ok");
}
const presignedUrl = await presignedResponse.json();
return presignedUrl;
}
async function uploadCsvToS3({
presignedUrl,
file,
}: {
presignedUrl: string;
file: File;
}) {
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.");
}
return { success: true };
}
export const SubmitPlan = ({
buttonDisabled,
goal,
housingType,
goalValue,
file,
portfolioId,
}: {
buttonDisabled: boolean;
goal: string;
housingType: string;
goalValue: string;
file: File;
portfolioId: string;
}) => {
const { handlePlanBuild, isGeneratingUrlLoading, isUploadLoading } =
useCreatePlan({ portfolioId, housingType, goal, goalValue, file });
let buttonText;
if (isUploadLoading) {
buttonText = "Uploading file...";
} else if (isGeneratingUrlLoading) {
buttonText = "Creating plan...";
} else {
buttonText = "Create";
}
return (
<button
type="button"
className="text-white inline-flex justify-center rounded-md border border-transparent bg-brandblue px-4 py-2 text-sm font-medium text-grey-900 hover:bg-hoverblue focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:bg-gray-300 disabled:opacity-50"
onClick={handlePlanBuild}
disabled={buttonDisabled || isGeneratingUrlLoading || isUploadLoading}
>
{buttonText}
</button>
);
};

View file

@ -1,205 +1,316 @@
"use client";
import { Menu, Dialog, Transition } from "@headlessui/react";
import { Fragment, useState } from "react";
import { ChevronDownIcon } from "@heroicons/react/20/solid";
import { Float } from "@headlessui-float/react";
import { InputFile } from "@/app/portfolio/[slug]/components/InputFile";
import { SubmitPlan } from "@/app/portfolio/[slug]/components/SubmitPlan";
import { Dialog, Transition } from "@headlessui/react";
import { Fragment, useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { InputFile } from "@/app/portfolio/[slug]/components/InputFile";
import { ScenarioSelect } from "@/app/db/schema/recommendations";
import MeasuresCheckboxes from "./MeasuresCheckboxes";
import { useForm, FormProvider } from "react-hook-form";
import {
SelectDropdown,
SelectScenarioDropdown,
} from "@/app/portfolio/[slug]/components/RemoteAssessmentDropdowns";
import { Button } from "@/app/shadcn_components/ui/button";
import {
FormField,
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";
type Option = {
label: string;
value: string;
disabled: boolean;
};
type DropdownProps = {
options: Option[];
selectedOption: string;
onSelectOption: (option: Option) => void;
};
export function SelectDropdown({
options,
selectedOption,
onSelectOption,
}: DropdownProps) {
return (
<Menu as="div" className="relative inline-block text-left w-full">
<Float>
<Menu.Button className="inline-flex justify-center w-1/2 px-4 py-2 text-sm font-medium text-white bg-brandblue rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75">
{selectedOption || "Select an option"}
<ChevronDownIcon
className="ml-2 -mr-1 h-5 w-5 text-violet-200 hover:text-violet-100"
aria-hidden="true"
/>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items className=" origin-bottom left-0 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none">
{options.map((option) => (
<Menu.Item key={option.value} disabled={option.disabled}>
{({ active }) => (
<button
className={`${
active
? "bg-brandmidblue text-white w-full"
: "text-gray-900 w-full"
} group flex items-center px-4 py-2 text-sm `}
onClick={() => onSelectOption(option)}
>
{option.label}
</button>
)}
</Menu.Item>
))}
</Menu.Items>
</Transition>
</Float>
</Menu>
);
}
const hiddenInputArrows =
"[-moz-appearance:_textfield] [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-outer-spin-button]:appearance-none";
const NEW_SENTINEL = "__new__";
const selecthousingTypeOptions = [
{
label: "Social",
value: "Social",
disabled: false,
},
{
label: "Private",
value: "Private",
disabled: false,
},
{ label: "Social", value: "Social", disabled: false },
{ label: "Private", value: "Private", disabled: false },
];
const selectGoalOptions = [
{ label: "Increasing EPC", value: "Increasing EPC", disabled: false },
// {
// label: "None",
// value: "None",
// label: "Reduce energy consumption",
// value: "Reduce energy consumption",
// disabled: false,
// },
{
label: "Increase EPC",
value: "Increase EPC",
disabled: false,
},
{
label: "Reduce energy consumption",
value: "Reduce energy consumption",
disabled: false,
},
];
const goalValueOptions = [
{
label: "C",
value: "C",
disabled: false,
},
{
label: "B",
value: "B",
disabled: false,
},
{
label: "A",
value: "A",
disabled: false,
},
{ label: "C", value: "C", disabled: false },
{ label: "B", value: "B", disabled: false },
{ label: "A", value: "A", disabled: false },
];
function generateS3Key(
userId: string,
portfolioId: string,
fileType: "csv" | "xlsx"
) {
const timestamp = new Date().toISOString().replace(/[:.-]/g, "");
return `${userId}/${portfolioId}/${timestamp}/asset_list.${fileType}`;
}
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,
fileType,
}: {
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;
fileType: "csv" | "xlsx";
}) {
const session = useSession();
const userId = String(session.data?.user.dbId);
const fileKey = useMemo(
() => generateS3Key(userId, portfolioId, fileType),
[userId, portfolioId, fileType]
);
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,
file_type: fileType, // Pass the file type for backend processing
};
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: (isOpen: boolean) => void;
setIsOpen: (open: boolean) => void;
portfolioId: string;
scenarios: ScenarioSelect[];
}) {
const [budget, setBudget] = useState<undefined | number>(undefined);
const form = useForm<UploadCsvFormValues>({
resolver: zodResolver(uploadCsvSchema),
shouldUnregister: false,
mode: "onChange",
defaultValues: {
scenario: "",
housingType: "",
goal: "",
goalValue: "",
budget: null,
measures: measuresList,
},
});
const { formState } = form;
const { isValid, isSubmitting } = formState;
const [buttonDisabled, setButtonDisabled] = useState(true);
const [selectedGoal, setSelectedGoal] = useState<string>("");
const [housingType, sethousingType] = useState<string>("");
const [goalValue, setGoalValue] = useState<string>("");
const router = useRouter();
const [sheetNames, setSheetNames] = useState<string[]>([]);
const [selectedSheet, setSelectedSheet] = useState("");
const [csvFile, setCsvFile] = useState<File | null>(null);
const [selectedScenario, setSelectedScenario] = useState<string | null>(null);
const [showMeasures, setShowMeasures] = useState(false);
const [fileType, setFileType] = useState<"csv" | "xlsx">("csv");
function handleBudgeChange(e: React.ChangeEvent<HTMLInputElement>) {
setBudget(e.target.valueAsNumber);
}
const scenarioOptions = useMemo(
() =>
scenarios.map((s) => ({
label: s.name || "",
value: String(s.id) || "",
disabled: false,
})),
[scenarios]
);
function handleButtonDisabled(
goal?: string,
housetype?: string,
value?: string,
file?: File | null
) {
// This function is defined as such to accomodate for the asynchonous nature of state setting
// The first time this is called, the setState function will be run before this but the state value
// will not have updated yet, so we need to pass in the value as an argument to check if the value has been updated
if (
(goal || selectedGoal) &&
(housetype || housingType) &&
(value || goalValue) &&
(file || csvFile)
) {
setButtonDisabled(false);
}
}
const {
mutate: validateFile,
isLoading: isValidating,
error: validationError,
} = useMutation(
const { mutate: validateFile, isLoading: isValidating } = useMutation(
async (file: File) => {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload/validate", {
method: "POST",
body: formData,
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "File validation failed");
}
if (!response.ok) throw new Error("File validation failed");
return response.json();
},
{
onSuccess: () => {
setButtonDisabled(false);
onSuccess: (data) => {
if (data.sheetNames) {
setSheetNames(data.sheetNames);
setSelectedSheet(data.sheetNames[0] || "");
} else {
setSheetNames([]);
setSelectedSheet("");
}
setFileType(data.file_type); // capture file type
},
onError: (err) => {
console.error(err);
setButtonDisabled(true);
onError: () => {
setSheetNames([]);
setSelectedSheet("");
},
}
);
const onSelectScenario = (opt: { value: string }) => {
setSelectedScenario(opt.value);
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);
if (!picked) return;
form.setValue("scenario", picked.name || "");
form.setValue("housingType", picked.housingType);
form.setValue("goal", picked.goal);
form.setValue("goalValue", picked.goalValue || "");
form.setValue("ashpCop", picked.ashpCop || 2.8); // Default COP value
form.setValue("budget", picked.budget || null);
}
};
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"),
fileType: fileType,
portfolioId,
selectedSheet,
onSuccessRedirect: (path) => router.push(path),
});
const onSubmit = form.handleSubmit(async () => {
if (!csvFile || !selectedSheet) return;
await uploadCsvPlan.handleSubmit();
form.reset();
});
return (
<>
<Transition appear show={isOpen} as={Fragment}>
<Dialog
as="div"
className="relative z-10"
onClose={() => setIsOpen(false)}
>
<Transition appear 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">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@ -209,170 +320,338 @@ export default function UploadCsvModal({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-25" />
<Dialog.Overlay className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
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"
>
<Dialog.Panel className="w-full max-w-screen-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
<Dialog.Title
as="h3"
className="flex justify-center text-lg font-medium leading-6 text-brandblue mb-3"
>
Upload Properties
</Dialog.Title>
<div className="text-gray-700 mb-7 mt-7 leading-relaxed tracking-wider">
Upload a csv of properties and input some details about how
you want to retrofit your properties
<span
className="inline-block h-screen align-middle"
aria-hidden="true"
>
&#8203;
</span>
<Transition.Child
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"
>
<div 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">
<Dialog.Title className="text-lg font-medium">
Upload Property Data
</Dialog.Title>
<FormProvider {...form}>
<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) => {
setCsvFile(file);
validateFile(file);
}}
isValidating={isValidating}
/>
<a
href="/example_properties.csv"
className="text-sm text-blue-600 underline w-fit"
>
View example CSV format
</a>
</div>
<div className="flex flex-col">
<label
htmlFor="csv-upload-budget"
className="text-sm font-semibold text-gray-600 mb-1 relative leading-relaxed tracking-wider"
>
Budget
</label>
<p className="text-sm text-gray-500 mb-2 leading-relaxed tracking-wider">
If you don&apos;t set a budget, we will aim to minimise
cost anyway
</p>
<div className="flex items-center border border-gray-200 rounded-md bg-gray-100">
<span className="mx-2 text-gray-700">£</span>
<input
id="csv-upload-budget"
type="number"
placeholder="Set a budget"
required
value={budget}
onChange={(e) => handleBudgeChange(e)}
onKeyDown={(e) =>
(e.key === "e" || e.key === "E") && e.preventDefault()
}
className="p-2 focus:outline-none bg-transparent w-full"
{/* Sheet selection */}
{sheetNames.length > 0 && (
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Select the sheet/tab you want to use:
</label>
<SelectDropdown
options={sheetNames.map((name) => ({
label: name,
value: name,
}))}
selectedOption={selectedSheet}
onSelectOption={(opt) => setSelectedSheet(opt.value)}
/>
<p className="text-xs text-gray-500">
Ensure this sheet uses the Standardised Asset List
format.
</p>
</div>
</div>
)}
<div className="flex flex-col mt-6">
<label
htmlFor="portfolio-name"
className="text-sm font-semibold text-gray-600 mb-1 relative leading-relaxed tracking-wider"
>
Housing type
<span className="text-red-500">*</span>
</label>
<SelectDropdown
options={selecthousingTypeOptions}
selectedOption={housingType}
onSelectOption={(option) => {
sethousingType(option.value);
handleButtonDisabled(
selectedGoal,
option.value,
goalValue,
csvFile
);
}}
/>
</div>
{/* Scenario selection */}
<FormItem>
<FormLabel>Select scenario</FormLabel>
<FormControl>
<SelectScenarioDropdown
scenarios={scenarioOptions}
selectedValue={selectedScenario}
onSelect={onSelectScenario}
/>
</FormControl>
</FormItem>
<div className="flex flex-col mt-6">
<label className="text-sm font-semibold text-gray-600 relative leading-relaxed tracking-wider">
Select your goal
<span className="text-red-500">*</span>
</label>
<SelectDropdown
options={selectGoalOptions}
selectedOption={selectedGoal}
onSelectOption={(option) => {
setSelectedGoal(option.value);
handleButtonDisabled(
option.value,
housingType,
goalValue,
csvFile
);
}}
/>
{selectedGoal === "Increase EPC" && (
<div className="flex flex-col mt-6">
<label
htmlFor="csv-upload-epc"
className="text-sm font-semibold text-gray-600 relative leading-relaxed tracking-wider"
>
Choose a target EPC value
<span className="text-red-500">*</span>
</label>
<SelectDropdown
options={goalValueOptions}
selectedOption={goalValue}
onSelectOption={(option) => {
setGoalValue(option.value);
handleButtonDisabled(
selectedGoal,
housingType,
option.value,
csvFile
);
}}
{/* Scenario definition inputs */}
{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>
<div className="flex flex-col space-y-2 mt-7">
<div className="text-sm font-semibold text-gray-600 relative leading-relaxed tracking-wider">
Download the example csv and fill in the details for your
properties
</div>
<div className="!text-blue-500 underline">
<a href="/example_properties.csv">Download example CSV</a>
</div>
</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>
)}
/>
<div className="flex flex-col space-y-2 mt-7">
<div className="flex space-x-2">
<InputFile
onFileSelect={(file) => validateFile(file)}
isValidating={isValidating}
/>
</div>
</div>
{/* 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>
<div className="mt-4 flex justify-end gap-2">
<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"
className="inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
onClick={() => setShowMeasures(!showMeasures)}
className="flex items-center justify-between w-full text-sm font-medium"
>
<span>Measures</span>
<span>{showMeasures ? "" : "+"}</span>
</button>
<div className={showMeasures ? "" : "hidden"}>
<MeasuresCheckboxes form={form} />
</div>
</div>
<div className="mt-auto pt-4 flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(false)}
>
Cancel
</button>
<SubmitPlan
buttonDisabled={buttonDisabled}
goal={selectedGoal}
housingType={housingType}
goalValue={goalValue}
portfolioId={portfolioId}
file={csvFile as File}
/>
</Button>
<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>
</Dialog.Panel>
</Transition.Child>
</form>
</FormProvider>
</div>
</div>
</Dialog>
</Transition>
</>
</Transition.Child>
</div>
</Dialog>
</Transition>
);
}