Skip to content

Commit ca9214d

Browse files
Merge pull request #410 from lishmanTech/multiForm
feat(asset-form): add multi-step asset registration form with validat…
2 parents a40c2e0 + df5ea8b commit ca9214d

File tree

17 files changed

+695
-38
lines changed

17 files changed

+695
-38
lines changed
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { useState, useEffect } from "react";
2+
import { useForm, FormProvider } from "react-hook-form";
3+
import { zodResolver } from "@hookform/resolvers/zod";
4+
import { assetSchema, AssetFormData } from "../schemas/assetSchema";
5+
import FormStep1 from "../../components/form/FormStep1";
6+
import FormStep2 from "../components/form/FormStep2";
7+
import FormStep3 from "../components/form/FormStep3";
8+
import FormStep4 from "../components/form/FormStep4";
9+
import ProgressBar from "../components/ProgressBar";
10+
import { saveToStorage, getFromStorage, clearStorage } from "../utils/storage";
11+
import { toast } from "react-toastify";
12+
import "react-toastify/dist/ReactToastify.css";
13+
import { createAsset } from "../utils/api";
14+
15+
const steps = [FormStep1, FormStep2, FormStep3, FormStep4];
16+
17+
const AssetFormPage = () => {
18+
const [currentStep, setCurrentStep] = useState(0);
19+
20+
const methods = useForm<AssetFormData>({
21+
resolver: zodResolver(assetSchema),
22+
defaultValues: getFromStorage("assetForm") || {},
23+
mode: "onChange",
24+
});
25+
26+
const CurrentStepComponent = steps[currentStep];
27+
28+
useEffect(() => {
29+
const subscription = methods.watch((data) => saveToStorage("assetForm", data));
30+
return () => subscription.unsubscribe();
31+
}, [methods]);
32+
33+
const nextStep = async () => {
34+
const isValid = await methods.trigger();
35+
if (isValid) setCurrentStep((prev) => Math.min(prev + 1, steps.length - 1));
36+
};
37+
38+
const prevStep = () => setCurrentStep((prev) => Math.max(prev - 1, 0));
39+
40+
const onSubmit = async (data: AssetFormData) => {
41+
try {
42+
await createAsset(data);
43+
toast.success("Asset created successfully!");
44+
clearStorage("assetForm");
45+
methods.reset();
46+
setCurrentStep(0);
47+
} catch (error: any) {
48+
toast.error(error.message || "Failed to create asset");
49+
}
50+
};
51+
52+
return (
53+
<div className="max-w-2xl mx-auto p-4">
54+
<ProgressBar step={currentStep + 1} total={steps.length} />
55+
<FormProvider {...methods}>
56+
<form onSubmit={methods.handleSubmit(onSubmit)}>
57+
<CurrentStepComponent />
58+
<div className="flex justify-between mt-4">
59+
{currentStep > 0 && (
60+
<button
61+
type="button"
62+
onClick={prevStep}
63+
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
64+
>
65+
Back
66+
</button>
67+
)}
68+
{currentStep < steps.length - 1 ? (
69+
<button
70+
type="button"
71+
onClick={nextStep}
72+
className="ml-auto px-4 py-2 bg-emerald-500 text-white rounded hover:bg-emerald-600"
73+
>
74+
Next
75+
</button>
76+
) : (
77+
<button
78+
type="submit"
79+
className="ml-auto px-4 py-2 bg-emerald-500 text-white rounded hover:bg-emerald-600"
80+
>
81+
Submit
82+
</button>
83+
)}
84+
</div>
85+
</form>
86+
</FormProvider>
87+
</div>
88+
);
89+
};
90+
91+
export default AssetFormPage;

frontend/app/utils/api.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { AssetFormData } from "./schemas/assetSchema";
2+
3+
export const createAsset = async (data: AssetFormData) => {
4+
const formData = new FormData();
5+
6+
Object.entries(data).forEach(([section, values]) => {
7+
if (typeof values === "object" && values !== null) {
8+
Object.entries(values).forEach(([key, value]) => {
9+
if (key === "images" && Array.isArray(value)) {
10+
value.forEach((file) => formData.append("images", file));
11+
} else formData.append(key, value as any);
12+
});
13+
}
14+
});
15+
16+
const res = await fetch("/api/assets", {
17+
method: "POST",
18+
body: formData,
19+
});
20+
21+
if (!res.ok) throw new Error("Failed to create asset");
22+
return res.json();
23+
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { z } from 'zod';
2+
3+
export const assetSchema = z.object({
4+
basic: z.object({
5+
name: z.string().min(2, "Asset name is required"),
6+
serialNumber: z.string().min(2, "Serial/ID is required"),
7+
category: z.string().min(1, "Select a category"),
8+
}),
9+
details: z.object({
10+
description: z.string().optional(),
11+
purchaseDate: z.string().min(1, "Purchase date is required"),
12+
purchasePrice: z
13+
.number({ invalid_type_error: "Must be a number" })
14+
.min(0, "Price must be positive"),
15+
warrantyExpiration: z.string().optional(),
16+
}),
17+
assignment: z.object({
18+
department: z.string().min(1, "Select a department"),
19+
location: z.string().min(1, "Select a location"),
20+
assignedUser: z.string().optional(),
21+
}),
22+
additional: z.object({
23+
status: z.enum(["active", "inactive"]),
24+
condition: z.enum(["new", "good", "fair", "poor"]),
25+
tags: z.array(z.string()).optional(),
26+
images: z.array(z.instanceof(File)).optional(),
27+
}),
28+
});
29+
30+
export type AssetFormData = z.infer<typeof assetSchema>;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { FC } from "react";
2+
3+
interface ProgressBarProps {
4+
step: number;
5+
total: number;
6+
}
7+
8+
const ProgressBar: FC<ProgressBarProps> = ({ step, total }) => {
9+
const percentage = (step / total) * 100;
10+
return (
11+
<div className="w-full bg-gray-200 rounded-full h-2 mb-4">
12+
<div
13+
className="bg-emerald-500 h-2 rounded-full transition-all"
14+
style={{ width: `${percentage}%` }}
15+
/>
16+
</div>
17+
);
18+
};
19+
20+
export default ProgressBar;

frontend/components/Toast.tsx

Whitespace-only changes.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { FC } from "react";
2+
import { FieldError } from "react-hook-form";
3+
4+
interface DatePickerProps extends React.InputHTMLAttributes<HTMLInputElement> {
5+
label: string;
6+
error?: FieldError;
7+
}
8+
9+
const DatePicker: FC<DatePickerProps> = ({ label, error, ...props }) => (
10+
<div className="mb-4">
11+
<label className="block text-sm font-medium text-gray-700">{label}</label>
12+
<input
13+
type="date"
14+
className={`mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-emerald-500 focus:ring focus:ring-emerald-200 ${
15+
error ? "border-red-500" : ""
16+
}`}
17+
{...props}
18+
/>
19+
{error && <p className="text-red-500 text-sm mt-1">{error.message}</p>}
20+
</div>
21+
);
22+
23+
export default DatePicker;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { FC, useState } from "react";
2+
import { FieldError } from "react-hook-form";
3+
4+
interface FileUploadProps {
5+
label: string;
6+
onChange: (files: File[]) => void;
7+
error?: FieldError;
8+
}
9+
10+
const FileUpload: FC<FileUploadProps> = ({ label, onChange, error }) => {
11+
const [previews, setPreviews] = useState<string[]>([]);
12+
13+
const handleFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
14+
const files = e.target.files ? Array.from(e.target.files) : [];
15+
onChange(files);
16+
setPreviews(files.map((file) => URL.createObjectURL(file)));
17+
};
18+
19+
return (
20+
<div className="mb-4">
21+
<label className="block text-sm font-medium text-gray-700">{label}</label>
22+
<input
23+
type="file"
24+
multiple
25+
onChange={handleFiles}
26+
className={`mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:bg-emerald-500 file:text-white hover:file:bg-emerald-600 ${
27+
error ? "border-red-500" : ""
28+
}`}
29+
/>
30+
<div className="flex mt-2 gap-2">
31+
{previews.map((src, idx) => (
32+
<img key={idx} src={src} alt={`preview-${idx}`} className="w-16 h-16 object-cover rounded" />
33+
))}
34+
</div>
35+
{error && <p className="text-red-500 text-sm mt-1">{error.message}</p>}
36+
</div>
37+
);
38+
};
39+
40+
export default FileUpload;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useFormContext } from "react-hook-form";
2+
import Input from "./Input";
3+
import Select from "./Select";
4+
5+
const categories = ["Laptop", "Monitor", "Furniture", "Other"];
6+
7+
const FormStep1 = () => {
8+
const { register, formState: { errors } } = useFormContext();
9+
10+
return (
11+
<div>
12+
<Input
13+
label="Asset Name"
14+
{...register("basic.name")}
15+
error={errors.basic?.name}
16+
/>
17+
<Input
18+
label="Asset ID / Serial Number"
19+
{...register("basic.serialNumber")}
20+
error={errors.basic?.serialNumber}
21+
/>
22+
<Select
23+
label="Category"
24+
options={categories}
25+
{...register("basic.category")}
26+
error={errors.basic?.category}
27+
/>
28+
</div>
29+
);
30+
};
31+
32+
export default FormStep1;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useFormContext } from "react-hook-form";
2+
import Input from "./Input";
3+
import TextArea from "./TextArea";
4+
import DatePicker from "./DatePicker";
5+
6+
const FormStep2 = () => {
7+
const { register, formState: { errors } } = useFormContext();
8+
9+
return (
10+
<div>
11+
<TextArea
12+
label="Description"
13+
{...register("details.description")}
14+
error={errors.details?.description}
15+
/>
16+
<DatePicker
17+
label="Purchase Date"
18+
{...register("details.purchaseDate")}
19+
error={errors.details?.purchaseDate}
20+
/>
21+
<Input
22+
label="Purchase Price"
23+
type="number"
24+
step="0.01"
25+
{...register("details.purchasePrice", { valueAsNumber: true })}
26+
error={errors.details?.purchasePrice}
27+
/>
28+
<DatePicker
29+
label="Warranty Expiration"
30+
{...register("details.warrantyExpiration")}
31+
error={errors.details?.warrantyExpiration}
32+
/>
33+
</div>
34+
);
35+
};
36+
37+
export default FormStep2;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { useFormContext } from "react-hook-form";
2+
import Input from "./Input";
3+
import Select from "./Select";
4+
5+
const departments = ["IT", "HR", "Finance", "Marketing"];
6+
const locations = ["HQ", "Branch 1", "Branch 2"];
7+
8+
const FormStep3 = () => {
9+
const { register, formState: { errors } } = useFormContext();
10+
11+
return (
12+
<div>
13+
<Select
14+
label="Department"
15+
options={departments}
16+
{...register("assignment.department")}
17+
error={errors.assignment?.department}
18+
/>
19+
<Select
20+
label="Location"
21+
options={locations}
22+
{...register("assignment.location")}
23+
error={errors.assignment?.location}
24+
/>
25+
<Input
26+
label="Assigned User (optional)"
27+
{...register("assignment.assignedUser")}
28+
error={errors.assignment?.assignedUser}
29+
/>
30+
</div>
31+
);
32+
};
33+
34+
export default FormStep3;

0 commit comments

Comments
 (0)