diff --git a/frontend/app/(auth)/layout.tsx b/frontend/app/(auth)/layout.tsx new file mode 100644 index 0000000..b1d459a --- /dev/null +++ b/frontend/app/(auth)/layout.tsx @@ -0,0 +1,27 @@ +import { ReactNode } from 'react'; + +export default function AuthLayout({ + children, +}: { + children: ReactNode; +}) { + return ( +
+
+ {/* Logo/Wordmark */} +
+
+

AssetsUp

+

Asset Management System

+
+
+
+ +
+
+ {children} +
+
+
+ ); +} diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx new file mode 100644 index 0000000..981be54 --- /dev/null +++ b/frontend/app/(auth)/login/page.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useLoginMutation } from "@/lib/query/mutations/auth"; +import { useAuthStore } from "@/store/auth.store"; +import { Button } from "@/components/ui/button"; + +// Zod schema for login validation +const loginSchema = z.object({ + email: z.string().email("Invalid email address"), + password: z.string().min(1, "Password is required"), +}); + +type LoginFormData = z.infer; + +export default function LoginPage() { + const router = useRouter(); + const { setAuth } = useAuthStore(); + const [apiError, setApiError] = useState(""); + + const { + register, + handleSubmit, + formState: { errors }, + setError, + } = useForm({ + resolver: zodResolver(loginSchema), + }); + + const loginMutation = useLoginMutation({ + onSuccess: (data) => { + setAuth(data.token, data.user); + router.push("/dashboard"); + }, + onError: (error: any) => { + if (error.statusCode === 401) { + setApiError("Invalid email or password"); + } else if (error.errors) { + // Handle field-specific errors from API + Object.entries(error.errors).forEach(([field, messages]) => { + setError(field as keyof LoginFormData, { + message: Array.isArray(messages) ? messages[0] : messages, + }); + }); + } else { + setApiError(error.message || "Login failed"); + } + }, + }); + + const onSubmit = (data: LoginFormData) => { + setApiError(""); + loginMutation.mutate(data); + }; + + return ( +
+
+

Sign in to your account

+

+ Or{" "} + + create a new account + +

+
+ +
+ {/* Email Field */} +
+ +
+ + {errors.email && ( +

{errors.email.message}

+ )} +
+
+ + {/* Password Field */} +
+ +
+ + {errors.password && ( +

{errors.password.message}

+ )} +
+
+ + {/* API Error */} + {apiError && ( +
+
{apiError}
+
+ )} + + {/* Submit Button */} +
+ +
+
+
+ ); +} diff --git a/frontend/app/(auth)/register/page.tsx b/frontend/app/(auth)/register/page.tsx new file mode 100644 index 0000000..93fe3d5 --- /dev/null +++ b/frontend/app/(auth)/register/page.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useRegisterMutation, useLoginMutation } from "@/lib/query/mutations/auth"; +import { useAuthStore } from "@/store/auth.store"; +import { Button } from "@/components/ui/button"; + +// Zod schema for registration validation +const registerSchema = z + .object({ + firstName: z.string().min(1, "First name is required"), + lastName: z.string().min(1, "Last name is required"), + email: z.string().email("Invalid email address"), + password: z.string().min(8, "Password must be at least 8 characters"), + confirmPassword: z.string().min(1, "Please confirm your password"), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"], + }); + +type RegisterFormData = z.infer; + +export default function RegisterPage() { + const router = useRouter(); + const { setAuth } = useAuthStore(); + const [apiError, setApiError] = useState(""); + + const [password, setPassword] = useState(""); + + const { + register, + handleSubmit, + formState: { errors }, + setError, + } = useForm({ + resolver: zodResolver(registerSchema), + }); + + const registerMutation = useRegisterMutation({ + onSuccess: (data) => { + // Auto-login after successful registration + loginMutation.mutate({ + email: data.user.email, + password: password, // Use the password from form state + }); + }, + onError: (error: any) => { + if (error.errors) { + // Handle field-specific errors from API + Object.entries(error.errors).forEach(([field, messages]) => { + setError(field as keyof RegisterFormData, { + message: Array.isArray(messages) ? messages[0] : messages, + }); + }); + } else { + setApiError(error.message || "Registration failed"); + } + }, + }); + + const loginMutation = useLoginMutation({ + onSuccess: (data) => { + setAuth(data.token, data.user); + router.push("/dashboard"); + }, + onError: (error: any) => { + setApiError("Registration successful but login failed. Please try logging in manually."); + }, + }); + + const onSubmit = (data: RegisterFormData) => { + setApiError(""); + setPassword(data.password); // Store password for auto-login + + const { confirmPassword, ...registerData } = data; + + registerMutation.mutate({ + ...registerData, + name: `${data.firstName} ${data.lastName}`, + }); + }; + + return ( +
+
+

Create your account

+

+ Already have an account?{" "} + + Sign in + +

+
+ +
+ {/* Name Fields */} +
+
+ +
+ + {errors.firstName && ( +

{errors.firstName.message}

+ )} +
+
+ +
+ +
+ + {errors.lastName && ( +

{errors.lastName.message}

+ )} +
+
+
+ + {/* Email Field */} +
+ +
+ + {errors.email && ( +

{errors.email.message}

+ )} +
+
+ + {/* Password Fields */} +
+
+ +
+ + {errors.password && ( +

{errors.password.message}

+ )} +
+
+ +
+ +
+ + {errors.confirmPassword && ( +

{errors.confirmPassword.message}

+ )} +
+
+
+ + {/* API Error */} + {apiError && ( +
+
{apiError}
+
+ )} + + {/* Submit Button */} +
+ +
+
+
+ ); +} diff --git a/frontend/app/(dashboard)/assets/page.tsx b/frontend/app/(dashboard)/assets/page.tsx new file mode 100644 index 0000000..6e808bb --- /dev/null +++ b/frontend/app/(dashboard)/assets/page.tsx @@ -0,0 +1,328 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Search, Plus, ChevronLeft, ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { StatusBadge } from "@/components/assets/status-badge"; +import { ConditionBadge } from "@/components/assets/condition-badge"; +import { useAssets } from "@/lib/query/hooks/useAsset"; +import { AssetStatus } from "@/lib/query/types/asset"; + +type SortField = "assetId" | "name" | "category" | "status" | "condition" | "department" | "assignedTo"; +type SortOrder = "asc" | "desc"; + +export default function AssetsPage() { + const router = useRouter(); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [sortField, setSortField] = useState("assetId"); + const [sortOrder, setSortOrder] = useState("asc"); + + const itemsPerPage = 10; + + // Debounce search + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(search); + setCurrentPage(1); // Reset to page 1 when search changes + }, 300); + + return () => clearTimeout(timer); + }, [search]); + + // Reset to page 1 when filter changes + useEffect(() => { + setCurrentPage(1); + }, [statusFilter]); + + const { data, isLoading, error } = useAssets({ + page: currentPage, + limit: itemsPerPage, + search: debouncedSearch, + status: statusFilter || undefined, + sortBy: sortField, + sortOrder, + }); + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortField(field); + setSortOrder("asc"); + } + }; + + const handleRowClick = (assetId: string) => { + router.push(`/assets/${assetId}`); + }; + + const handlePreviousPage = () => { + setCurrentPage((prev) => Math.max(1, prev - 1)); + }; + + const handleNextPage = () => { + setCurrentPage((prev) => Math.min(data?.totalPages || 1, prev + 1)); + }; + + if (error) { + return ( +
+

Error loading assets.

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+

Assets

+

+ {data?.total || 0} total assets +

+
+ +
+
+ + {/* Filters */} +
+
+ {/* Search */} +
+
+ + setSearch(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
+ + {/* Status Filter */} +
+ +
+
+
+ + {/* Loading State */} + {isLoading && ( +
+
+
+

Loading assets...

+
+
+ )} + + {/* Table */} + {!isLoading && data && ( +
+ {data.assets.length === 0 ? ( +
+

+ {debouncedSearch || statusFilter + ? "No assets found matching your filters." + : "No assets registered yet."} +

+ {!debouncedSearch && !statusFilter && ( + + )} +
+ ) : ( + <> +
+ + + + + + + + + + + + + + {data.assets.map((asset) => ( + handleRowClick(asset.id)} + className="hover:bg-gray-50 cursor-pointer transition-colors" + > + + + + + + + + + ))} + +
+ + + + + + + + + + + + + +
+ {asset.assetId} + + {asset.name} + + {asset.category?.name || "—"} + + + + + + {asset.department?.name || "—"} + + {asset.assignedTo + ? `${asset.assignedTo.name}` + : "Unassigned"} +
+
+ + {/* Pagination */} + {data.totalPages > 1 && ( +
+
+
+ Showing {((currentPage - 1) * itemsPerPage) + 1} to{" "} + {Math.min(currentPage * itemsPerPage, data.total)} of{" "} + {data.total} results +
+
+ + + Page {currentPage} of {data.totalPages} + + +
+
+
+ )} + + )} +
+ )} +
+ ); +} diff --git a/frontend/components/assets/condition-badge.tsx b/frontend/components/assets/condition-badge.tsx new file mode 100644 index 0000000..a0a3f65 --- /dev/null +++ b/frontend/components/assets/condition-badge.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { AssetCondition } from "@/lib/query/types/asset"; + +const conditionConfig = { + [AssetCondition.NEW]: { + label: "New", + className: "bg-emerald-100 text-emerald-800 border-emerald-200", + }, + [AssetCondition.GOOD]: { + label: "Good", + className: "bg-green-100 text-green-800 border-green-200", + }, + [AssetCondition.FAIR]: { + label: "Fair", + className: "bg-blue-100 text-blue-800 border-blue-200", + }, + [AssetCondition.POOR]: { + label: "Poor", + className: "bg-orange-100 text-orange-800 border-orange-200", + }, + [AssetCondition.DAMAGED]: { + label: "Damaged", + className: "bg-red-100 text-red-800 border-red-200", + }, +}; + +interface ConditionBadgeProps { + condition: AssetCondition; +} + +export function ConditionBadge({ condition }: ConditionBadgeProps) { + const config = conditionConfig[condition]; + + if (!config) { + return ( + + {condition} + + ); + } + + return ( + + {config.label} + + ); +} diff --git a/frontend/components/assets/status-badge.tsx b/frontend/components/assets/status-badge.tsx new file mode 100644 index 0000000..e6c5508 --- /dev/null +++ b/frontend/components/assets/status-badge.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { AssetStatus } from "@/lib/query/types/asset"; + +const statusConfig = { + [AssetStatus.ACTIVE]: { + label: "Active", + className: "bg-green-100 text-green-800 border-green-200", + }, + [AssetStatus.ASSIGNED]: { + label: "Assigned", + className: "bg-blue-100 text-blue-800 border-blue-200", + }, + [AssetStatus.MAINTENANCE]: { + label: "Maintenance", + className: "bg-yellow-100 text-yellow-800 border-yellow-200", + }, + [AssetStatus.RETIRED]: { + label: "Retired", + className: "bg-gray-100 text-gray-800 border-gray-200", + }, +}; + +interface StatusBadgeProps { + status: AssetStatus; +} + +export function StatusBadge({ status }: StatusBadgeProps) { + const config = statusConfig[status]; + + if (!config) { + return ( + + {status} + + ); + } + + return ( + + {config.label} + + ); +} diff --git a/frontend/components/ui/button-simple.tsx b/frontend/components/ui/button-simple.tsx new file mode 100644 index 0000000..68cbb3b --- /dev/null +++ b/frontend/components/ui/button-simple.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +export interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'default' | 'outline' | 'destructive' | 'secondary' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg' | 'icon'; +} + +const Button = React.forwardRef( + ({ className = '', variant = 'default', size = 'default', ...props }, ref) => { + const baseClasses = 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50'; + + const variantClasses = { + default: 'bg-blue-600 text-white hover:bg-blue-700', + destructive: 'bg-red-600 text-white hover:bg-red-700', + outline: 'border border-gray-300 bg-white hover:bg-gray-50', + secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200', + ghost: 'hover:bg-gray-100', + link: 'text-blue-600 underline-offset-4 hover:underline', + }; + + const sizeClasses = { + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', + }; + + const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`; + + return ( +