Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions frontend/app/pages/AssetFormPage.tsx
Original file line number Diff line number Diff line change
@@ -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<AssetFormData>({
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 (
<div className="max-w-2xl mx-auto p-4">
<ProgressBar step={currentStep + 1} total={steps.length} />
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<CurrentStepComponent />
<div className="flex justify-between mt-4">
{currentStep > 0 && (
<button
type="button"
onClick={prevStep}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
Back
</button>
)}
{currentStep < steps.length - 1 ? (
<button
type="button"
onClick={nextStep}
className="ml-auto px-4 py-2 bg-emerald-500 text-white rounded hover:bg-emerald-600"
>
Next
</button>
) : (
<button
type="submit"
className="ml-auto px-4 py-2 bg-emerald-500 text-white rounded hover:bg-emerald-600"
>
Submit
</button>
)}
</div>
</form>
</FormProvider>
</div>
);
};

export default AssetFormPage;
23 changes: 23 additions & 0 deletions frontend/app/utils/api.ts
Original file line number Diff line number Diff line change
@@ -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();
};
30 changes: 30 additions & 0 deletions frontend/app/utils/schemas/assetSchema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof assetSchema>;
20 changes: 20 additions & 0 deletions frontend/components/ProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { FC } from "react";

interface ProgressBarProps {
step: number;
total: number;
}

const ProgressBar: FC<ProgressBarProps> = ({ step, total }) => {
const percentage = (step / total) * 100;
return (
<div className="w-full bg-gray-200 rounded-full h-2 mb-4">
<div
className="bg-emerald-500 h-2 rounded-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
);
};

export default ProgressBar;
Empty file added frontend/components/Toast.tsx
Empty file.
23 changes: 23 additions & 0 deletions frontend/components/form/DatePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { FC } from "react";
import { FieldError } from "react-hook-form";

interface DatePickerProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: FieldError;
}

const DatePicker: FC<DatePickerProps> = ({ label, error, ...props }) => (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700">{label}</label>
<input
type="date"
className={`mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-emerald-500 focus:ring focus:ring-emerald-200 ${
error ? "border-red-500" : ""
}`}
{...props}
/>
{error && <p className="text-red-500 text-sm mt-1">{error.message}</p>}
</div>
);

export default DatePicker;
40 changes: 40 additions & 0 deletions frontend/components/form/FileUpload.tsx
Original file line number Diff line number Diff line change
@@ -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<FileUploadProps> = ({ label, onChange, error }) => {
const [previews, setPreviews] = useState<string[]>([]);

const handleFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files ? Array.from(e.target.files) : [];
onChange(files);
setPreviews(files.map((file) => URL.createObjectURL(file)));
};

return (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700">{label}</label>
<input
type="file"
multiple
onChange={handleFiles}
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 ${
error ? "border-red-500" : ""
}`}
/>
<div className="flex mt-2 gap-2">
{previews.map((src, idx) => (
<img key={idx} src={src} alt={`preview-${idx}`} className="w-16 h-16 object-cover rounded" />
))}
</div>
{error && <p className="text-red-500 text-sm mt-1">{error.message}</p>}
</div>
);
};

export default FileUpload;
32 changes: 32 additions & 0 deletions frontend/components/form/FormStep1.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<Input
label="Asset Name"
{...register("basic.name")}
error={errors.basic?.name}
/>
<Input
label="Asset ID / Serial Number"
{...register("basic.serialNumber")}
error={errors.basic?.serialNumber}
/>
<Select
label="Category"
options={categories}
{...register("basic.category")}
error={errors.basic?.category}
/>
</div>
);
};

export default FormStep1;
37 changes: 37 additions & 0 deletions frontend/components/form/FormStep2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useFormContext } from "react-hook-form";
import Input from "./Input";
import TextArea from "./TextArea";
import DatePicker from "./DatePicker";

const FormStep2 = () => {
const { register, formState: { errors } } = useFormContext();

return (
<div>
<TextArea
label="Description"
{...register("details.description")}
error={errors.details?.description}
/>
<DatePicker
label="Purchase Date"
{...register("details.purchaseDate")}
error={errors.details?.purchaseDate}
/>
<Input
label="Purchase Price"
type="number"
step="0.01"
{...register("details.purchasePrice", { valueAsNumber: true })}
error={errors.details?.purchasePrice}
/>
<DatePicker
label="Warranty Expiration"
{...register("details.warrantyExpiration")}
error={errors.details?.warrantyExpiration}
/>
</div>
);
};

export default FormStep2;
34 changes: 34 additions & 0 deletions frontend/components/form/FormStep3.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useFormContext } from "react-hook-form";
import Input from "./Input";
import Select from "./Select";

const departments = ["IT", "HR", "Finance", "Marketing"];
const locations = ["HQ", "Branch 1", "Branch 2"];

const FormStep3 = () => {
const { register, formState: { errors } } = useFormContext();

return (
<div>
<Select
label="Department"
options={departments}
{...register("assignment.department")}
error={errors.assignment?.department}
/>
<Select
label="Location"
options={locations}
{...register("assignment.location")}
error={errors.assignment?.location}
/>
<Input
label="Assigned User (optional)"
{...register("assignment.assignedUser")}
error={errors.assignment?.assignedUser}
/>
</div>
);
};

export default FormStep3;
Loading