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 ( +