diff --git a/frontend/app/pages/AssetFormPage.tsx b/frontend/app/pages/AssetFormPage.tsx new file mode 100644 index 0000000..9486272 --- /dev/null +++ b/frontend/app/pages/AssetFormPage.tsx @@ -0,0 +1,91 @@ +import { useState, useEffect } from "react"; +import { useForm, FormProvider } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { assetSchema, AssetFormData } from "../schemas/assetSchema"; +import FormStep1 from "../../components/form/FormStep1"; +import FormStep2 from "../components/form/FormStep2"; +import FormStep3 from "../components/form/FormStep3"; +import FormStep4 from "../components/form/FormStep4"; +import ProgressBar from "../components/ProgressBar"; +import { saveToStorage, getFromStorage, clearStorage } from "../utils/storage"; +import { toast } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import { createAsset } from "../utils/api"; + +const steps = [FormStep1, FormStep2, FormStep3, FormStep4]; + +const AssetFormPage = () => { + const [currentStep, setCurrentStep] = useState(0); + + const methods = useForm({ + resolver: zodResolver(assetSchema), + defaultValues: getFromStorage("assetForm") || {}, + mode: "onChange", + }); + + const CurrentStepComponent = steps[currentStep]; + + useEffect(() => { + const subscription = methods.watch((data) => saveToStorage("assetForm", data)); + return () => subscription.unsubscribe(); + }, [methods]); + + const nextStep = async () => { + const isValid = await methods.trigger(); + if (isValid) setCurrentStep((prev) => Math.min(prev + 1, steps.length - 1)); + }; + + const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 0)); + + const onSubmit = async (data: AssetFormData) => { + try { + await createAsset(data); + toast.success("Asset created successfully!"); + clearStorage("assetForm"); + methods.reset(); + setCurrentStep(0); + } catch (error: any) { + toast.error(error.message || "Failed to create asset"); + } + }; + + return ( +
+ + +
+ +
+ {currentStep > 0 && ( + + )} + {currentStep < steps.length - 1 ? ( + + ) : ( + + )} +
+ +
+
+ ); +}; + +export default AssetFormPage; diff --git a/frontend/app/utils/api.ts b/frontend/app/utils/api.ts new file mode 100644 index 0000000..fea4d61 --- /dev/null +++ b/frontend/app/utils/api.ts @@ -0,0 +1,23 @@ +import { AssetFormData } from "./schemas/assetSchema"; + +export const createAsset = async (data: AssetFormData) => { + const formData = new FormData(); + + Object.entries(data).forEach(([section, values]) => { + if (typeof values === "object" && values !== null) { + Object.entries(values).forEach(([key, value]) => { + if (key === "images" && Array.isArray(value)) { + value.forEach((file) => formData.append("images", file)); + } else formData.append(key, value as any); + }); + } + }); + + const res = await fetch("/api/assets", { + method: "POST", + body: formData, + }); + + if (!res.ok) throw new Error("Failed to create asset"); + return res.json(); +}; diff --git a/frontend/app/utils/schemas/assetSchema.ts b/frontend/app/utils/schemas/assetSchema.ts new file mode 100644 index 0000000..b12205b --- /dev/null +++ b/frontend/app/utils/schemas/assetSchema.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +export const assetSchema = z.object({ + basic: z.object({ + name: z.string().min(2, "Asset name is required"), + serialNumber: z.string().min(2, "Serial/ID is required"), + category: z.string().min(1, "Select a category"), + }), + details: z.object({ + description: z.string().optional(), + purchaseDate: z.string().min(1, "Purchase date is required"), + purchasePrice: z + .number({ invalid_type_error: "Must be a number" }) + .min(0, "Price must be positive"), + warrantyExpiration: z.string().optional(), + }), + assignment: z.object({ + department: z.string().min(1, "Select a department"), + location: z.string().min(1, "Select a location"), + assignedUser: z.string().optional(), + }), + additional: z.object({ + status: z.enum(["active", "inactive"]), + condition: z.enum(["new", "good", "fair", "poor"]), + tags: z.array(z.string()).optional(), + images: z.array(z.instanceof(File)).optional(), + }), +}); + +export type AssetFormData = z.infer; diff --git a/frontend/components/ProgressBar.tsx b/frontend/components/ProgressBar.tsx new file mode 100644 index 0000000..06ef286 --- /dev/null +++ b/frontend/components/ProgressBar.tsx @@ -0,0 +1,20 @@ +import { FC } from "react"; + +interface ProgressBarProps { + step: number; + total: number; +} + +const ProgressBar: FC = ({ step, total }) => { + const percentage = (step / total) * 100; + return ( +
+
+
+ ); +}; + +export default ProgressBar; diff --git a/frontend/components/Toast.tsx b/frontend/components/Toast.tsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/components/form/DatePicker.tsx b/frontend/components/form/DatePicker.tsx new file mode 100644 index 0000000..705f2fa --- /dev/null +++ b/frontend/components/form/DatePicker.tsx @@ -0,0 +1,23 @@ +import { FC } from "react"; +import { FieldError } from "react-hook-form"; + +interface DatePickerProps extends React.InputHTMLAttributes { + label: string; + error?: FieldError; +} + +const DatePicker: FC = ({ label, error, ...props }) => ( +
+ + + {error &&

{error.message}

} +
+); + +export default DatePicker; diff --git a/frontend/components/form/FileUpload.tsx b/frontend/components/form/FileUpload.tsx new file mode 100644 index 0000000..a86d93d --- /dev/null +++ b/frontend/components/form/FileUpload.tsx @@ -0,0 +1,40 @@ +import { FC, useState } from "react"; +import { FieldError } from "react-hook-form"; + +interface FileUploadProps { + label: string; + onChange: (files: File[]) => void; + error?: FieldError; +} + +const FileUpload: FC = ({ label, onChange, error }) => { + const [previews, setPreviews] = useState([]); + + const handleFiles = (e: React.ChangeEvent) => { + const files = e.target.files ? Array.from(e.target.files) : []; + onChange(files); + setPreviews(files.map((file) => URL.createObjectURL(file))); + }; + + return ( +
+ + +
+ {previews.map((src, idx) => ( + {`preview-${idx}`} + ))} +
+ {error &&

{error.message}

} +
+ ); +}; + +export default FileUpload; diff --git a/frontend/components/form/FormStep1.tsx b/frontend/components/form/FormStep1.tsx new file mode 100644 index 0000000..4f06753 --- /dev/null +++ b/frontend/components/form/FormStep1.tsx @@ -0,0 +1,32 @@ +import { useFormContext } from "react-hook-form"; +import Input from "./Input"; +import Select from "./Select"; + +const categories = ["Laptop", "Monitor", "Furniture", "Other"]; + +const FormStep1 = () => { + const { register, formState: { errors } } = useFormContext(); + + return ( +
+ + +