diff --git a/src/app/components/portfolio/Toolbar.tsx b/src/app/components/portfolio/Toolbar.tsx index e3f747fa..25afc698 100644 --- a/src/app/components/portfolio/Toolbar.tsx +++ b/src/app/components/portfolio/Toolbar.tsx @@ -8,7 +8,7 @@ import { } from "@/app/shadcn_components/ui/navigation-menu"; import AddNewDropDown from "./AddNew"; import { cva } from "class-variance-authority"; -import UploadCsvModal from "./UploadCsvModal"; +import UploadCsvModal from "@/app/portfolio/[slug]/components/UploadCsvModal"; import { useState } from "react"; interface ToolbarProps { diff --git a/src/app/portfolio/[slug]/components/InputFile.tsx b/src/app/portfolio/[slug]/components/InputFile.tsx new file mode 100644 index 00000000..90396155 --- /dev/null +++ b/src/app/portfolio/[slug]/components/InputFile.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Input } from "@/app/shadcn_components/ui/input"; +import { Label } from "@/app/shadcn_components/ui/label"; + +export function InputFile({ + handleButtonDisabled, + setCsvFile, + selectedGoal, + housingType, + goalValue, +}: { + handleButtonDisabled: ( + goal?: string, + scheme?: string, + value?: string, + file?: File | null + ) => void; + setCsvFile: (file: File) => void; + selectedGoal: string; + housingType: string; + goalValue: string; +}) { + function handleOnChange(e: React.ChangeEvent) { + if (e.target.files) { + // Check if files is not null + const file = e.target.files[0]; + if (file.type !== "text/csv") { + // Show an error message + console.error("File is not a CSV"); + return; + } + setCsvFile(file); // Assuming you have a state to keep the file + handleButtonDisabled(selectedGoal, housingType, goalValue, file); + } + } + + return ( +
+ + +
+ ); +} diff --git a/src/app/portfolio/[slug]/components/SubmitPlan.tsx b/src/app/portfolio/[slug]/components/SubmitPlan.tsx new file mode 100644 index 00000000..3dc1ee81 --- /dev/null +++ b/src/app/portfolio/[slug]/components/SubmitPlan.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useMutation } from "@tanstack/react-query"; +import { useSession } from "next-auth/react"; + +function generateS3Key(userId: number, portfolioId: number, filename: string) { + const timestamp = new Date().toISOString().replace(/[:.-]/g, ""); + const key = `${userId}/${portfolioId}/${filename}-${timestamp}.csv`; + return key; +} + +interface UseCreatePlanProps { + portfolioId: number; + 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(); + + const userId = session.data?.user.dbId; + + const fileKey = generateS3Key( + userId, + portfolioId, + "portfolio_plan_properties" + ); + + const { mutate: mutateUploadCsv, isLoading: isUploadLoading } = useMutation( + uploadCsvToS3, + { + onSuccess: (data) => { + 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: number; + portfolioId: number; + 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: number; +}) => { + const router = useRouter(); + const session = useSession(); + + 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 ( + + ); +}; diff --git a/src/app/components/portfolio/UploadCsvModal.tsx b/src/app/portfolio/[slug]/components/UploadCsvModal.tsx similarity index 68% rename from src/app/components/portfolio/UploadCsvModal.tsx rename to src/app/portfolio/[slug]/components/UploadCsvModal.tsx index f6f99729..91d6e6b6 100644 --- a/src/app/components/portfolio/UploadCsvModal.tsx +++ b/src/app/portfolio/[slug]/components/UploadCsvModal.tsx @@ -4,224 +4,8 @@ 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 { Input } from "@/app/shadcn_components/ui/input"; -import { Label } from "@/app/shadcn_components/ui/label"; -import { useRouter } from "next/navigation"; -import { useMutation } from "@tanstack/react-query"; -import { useSession } from "next-auth/react"; - -function generateS3Key(userId: number, portfolioId: number, filename: string) { - const timestamp = new Date().toISOString().replace(/[:.-]/g, ""); - const key = `${userId}/${portfolioId}/${filename}-${timestamp}.csv`; - return key; -} - -async function generatePresignedUrl({ - userId, - portfolioId, - fileKey, -}: { - userId: number; - portfolioId: number; - 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: number; -}) => { - const router = useRouter(); - const session = useSession(); - - const userId = session.data?.user.dbId; - - const fileKey = generateS3Key( - userId, - portfolioId, - "portfolio_plan_properties" - ); - - const { mutate: mutateUploadCsv, isLoading: isUploadLoading } = useMutation( - uploadCsvToS3, - { - onSuccess: (data) => { - const body = JSON.stringify({ - // userId: session.data?.user.dbId, - portfolio_id: portfolioId, - housing_type: housingType, - goal: goal, - goal_value: goalValue, - trigger_file_path: fileKey, - }); - - // After the file has been uploaded, we can trigger the job to build the plan - const response = fetch(`/api/plan/trigger`, { - method: "POST", - body: body, - }); - return response; - }, - onError: (error) => { - // handle error - console.error(error); - }, - } - ); - - const { mutate, isLoading } = useMutation(generatePresignedUrl, { - onSuccess: (data) => { - // After the presigned URL has been generated, we can upload the file to S3 - try { - const response = mutateUploadCsv({ - presignedUrl: data.url, - file: file, - }); - - return response; - } catch (error) { - console.error(error); - } - }, - onError: (error) => { - // handle error - console.error(error); - }, - }); - - if (!session.data) { - // The user is not logged in, redirect them to sign in - router.push("/"); - return null; - } - - const handlePlanBuild = () => { - // The plan build is triggered by clicking submit which will: - // 1) Generate a pre-signed url to upload to - // 2) Upload the csv to the pre-signed url - // 3) Trigger the job to build the plan - // 4) Redirect the user to some loading page - this could be the portfolio page itself and we just trigger a regresh with skeleton cards for the properties - mutate({ userId: userId, portfolioId: portfolioId, fileKey: fileKey }); - - // TODO: Make api call to backend service to trigger the plan build - // Probably need to pass in the file key to mutate (define it outside) - // because of the async - // We could also trigger it inside of mutateUploadCsv but the nested nature is kind of ugly - - console.log("Redirect user to loading page"); - router.push("/temp-loading"); - }; - - return ( - - ); -}; - -export function InputFile({ - handleButtonDisabled, - setCsvFile, - selectedGoal, - housingType, - goalValue, -}: { - handleButtonDisabled: ( - goal?: string, - scheme?: string, - value?: string, - file?: File | null - ) => void; - setCsvFile: (file: File) => void; - selectedGoal: string; - housingType: string; - goalValue: string; -}) { - function handleOnChange(e: React.ChangeEvent) { - if (e.target.files) { - // Check if files is not null - const file = e.target.files[0]; - if (file.type !== "text/csv") { - // Show an error message - console.error("File is not a CSV"); - return; - } - setCsvFile(file); // Assuming you have a state to keep the file - handleButtonDisabled(selectedGoal, housingType, goalValue, file); - } - } - - return ( -
- - -
- ); -} +import { InputFile } from "@/app/portfolio/[slug]/components/InputFile"; +import { SubmitPlan } from "@/app/portfolio/[slug]/components/SubmitPlan"; type Option = { label: string; diff --git a/src/app/temp-loading/page.tsx b/src/app/portfolio/[slug]/plan-loading/page.tsx similarity index 100% rename from src/app/temp-loading/page.tsx rename to src/app/portfolio/[slug]/plan-loading/page.tsx