Spaces:
Sleeping
Sleeping
| import React, { useState, useMemo } from 'react'; | |
| import { | |
| Table, | |
| TableBody, | |
| TableCell, | |
| TableHead, | |
| TableHeader, | |
| TableRow, | |
| } from '@/components/ui/table'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Input } from '@/components/ui/input'; | |
| import { CustomDropdown } from './shared/CustomDropdown'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { | |
| Search, | |
| Eye, | |
| Edit, | |
| Trash2, | |
| Plus, | |
| ArrowUpDown, | |
| ArrowUp, | |
| ArrowDown, | |
| Grid3X3, | |
| List, | |
| UserPlus, | |
| User | |
| } from 'lucide-react'; | |
| import { Asset, AssetCondition, ASSET_CONDITIONS, ASSET_TYPES, AssetAssignment } from '@/types/asset-management'; | |
| import { LoadingSpinner } from './shared'; | |
| import Pagination from './shared/Pagination'; | |
| import AssetCard from './shared/AssetCard'; | |
| import { usePagination } from '@/hooks/asset-management/usePagination'; | |
| interface AssetListProps { | |
| assets: Asset[]; | |
| assignments?: AssetAssignment[]; | |
| isLoading?: boolean; | |
| onView?: (asset: Asset) => void; | |
| onEdit?: (asset: Asset) => void; | |
| onDelete?: (asset: Asset) => void; | |
| onCreate?: () => void; | |
| onAssign?: (asset: Asset) => void; | |
| showActions?: boolean; | |
| } | |
| type SortField = keyof Asset; | |
| type SortDirection = 'asc' | 'desc'; | |
| interface SortConfig { | |
| field: SortField; | |
| direction: SortDirection; | |
| } | |
| const AssetList: React.FC<AssetListProps> = ({ | |
| assets, | |
| assignments = [], | |
| isLoading = false, | |
| onView, | |
| onEdit, | |
| onDelete, | |
| onCreate, | |
| onAssign, | |
| showActions = true | |
| }) => { | |
| const [searchTerm, setSearchTerm] = useState(''); | |
| const [typeFilter, setTypeFilter] = useState<string>('all'); | |
| const [conditionFilter, setConditionFilter] = useState<string>('all'); | |
| const [assignmentStatusFilter, setAssignmentStatusFilter] = useState<string>('all'); | |
| const [sortConfig, setSortConfig] = useState<SortConfig>({ field: 'assetCode', direction: 'asc' }); | |
| const [viewMode, setViewMode] = useState<'table' | 'cards'>('table'); | |
| // Helper function to get badge variant based on asset condition | |
| const getConditionBadgeVariant = (condition: AssetCondition) => { | |
| switch (condition) { | |
| case 'Excellent': | |
| return 'default'; | |
| case 'Good': | |
| return 'secondary'; | |
| case 'Fair': | |
| return 'outline'; | |
| case 'Poor': | |
| return 'destructive'; | |
| default: | |
| return 'outline'; | |
| } | |
| }; | |
| // Helper function to get active assignment for an asset | |
| const getActiveAssignment = (assetId: number): AssetAssignment | undefined => { | |
| return assignments.find( | |
| assignment => assignment.assetID === assetId && !assignment.returnedDate | |
| ); | |
| }; | |
| // Helper function to check if asset is available for assignment | |
| const isAssetAvailable = (assetId: number): boolean => { | |
| return !getActiveAssignment(assetId); | |
| }; | |
| // Helper function to get assignment status for an asset | |
| const getAssetAssignmentStatus = (assetId: number) => { | |
| return getActiveAssignment(assetId) ? 'assigned' : 'unassigned'; | |
| }; | |
| // Filter and sort assets | |
| const filteredAndSortedAssets = useMemo(() => { | |
| let filtered = assets.filter(asset => { | |
| const matchesSearch = | |
| asset.assetType.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| asset.brandModel.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| asset.serialNumber.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| asset.assetCode.toLowerCase().includes(searchTerm.toLowerCase()); | |
| const matchesType = typeFilter === 'all' || asset.assetType === typeFilter; | |
| const matchesCondition = conditionFilter === 'all' || asset.assetCondition === conditionFilter; | |
| const assetAssignmentStatus = getAssetAssignmentStatus(asset.assetID); | |
| const matchesAssignmentStatus = assignmentStatusFilter === 'all' || assetAssignmentStatus === assignmentStatusFilter; | |
| return matchesSearch && matchesType && matchesCondition && matchesAssignmentStatus; | |
| }); | |
| // Sort assets | |
| filtered.sort((a, b) => { | |
| const aValue = a[sortConfig.field]; | |
| const bValue = b[sortConfig.field]; | |
| if (typeof aValue === 'string' && typeof bValue === 'string') { | |
| const comparison = aValue.localeCompare(bValue); | |
| return sortConfig.direction === 'asc' ? comparison : -comparison; | |
| } | |
| if (typeof aValue === 'number' && typeof bValue === 'number') { | |
| const comparison = aValue - bValue; | |
| return sortConfig.direction === 'asc' ? comparison : -comparison; | |
| } | |
| return 0; | |
| }); | |
| return filtered; | |
| }, [assets, assignments, searchTerm, typeFilter, conditionFilter, assignmentStatusFilter, sortConfig]); | |
| // Use pagination hook | |
| const { | |
| currentPage, | |
| itemsPerPage, | |
| totalPages, | |
| paginatedData: paginatedAssets, | |
| setCurrentPage, | |
| setItemsPerPage, | |
| } = usePagination(filteredAndSortedAssets); | |
| const handleSort = (field: SortField) => { | |
| setSortConfig(prev => ({ | |
| field, | |
| direction: prev.field === field && prev.direction === 'asc' ? 'desc' : 'asc' | |
| })); | |
| }; | |
| const getSortIcon = (field: SortField) => { | |
| if (sortConfig.field !== field) { | |
| return <ArrowUpDown className="h-4 w-4" />; | |
| } | |
| return sortConfig.direction === 'asc' | |
| ? <ArrowUp className="h-4 w-4" /> | |
| : <ArrowDown className="h-4 w-4" />; | |
| }; | |
| if (isLoading) { | |
| return ( | |
| <div className="space-y-4"> | |
| <div className="flex justify-between items-center"> | |
| <h2 className="text-2xl font-bold">Assets</h2> | |
| </div> | |
| <LoadingSpinner size="lg" text="Loading assets..." className="py-8" /> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="space-y-4"> | |
| {/* Header */} | |
| <div className="flex justify-between items-center"> | |
| <h2 className="text-2xl font-bold">Assets</h2> | |
| <div className="flex items-center gap-2"> | |
| {/* View Mode Toggle */} | |
| <div className="hidden md:flex border rounded-lg p-1"> | |
| <Button | |
| variant={viewMode === 'table' ? 'default' : 'ghost'} | |
| size="sm" | |
| onClick={() => setViewMode('table')} | |
| className="h-8 px-3" | |
| aria-label="Table view" | |
| > | |
| <List className="h-4 w-4" /> | |
| </Button> | |
| <Button | |
| variant={viewMode === 'cards' ? 'default' : 'ghost'} | |
| size="sm" | |
| onClick={() => setViewMode('cards')} | |
| className="h-8 px-3" | |
| aria-label="Card view" | |
| > | |
| <Grid3X3 className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| {onCreate && ( | |
| <Button onClick={onCreate} className="flex items-center gap-2"> | |
| <Plus className="h-4 w-4" /> | |
| Add Asset | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| {/* Filters */} | |
| <div className="flex flex-col gap-4" role="search" aria-label="Asset search and filters"> | |
| <div className="flex flex-col sm:flex-row gap-4"> | |
| <div className="relative flex-1"> | |
| <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4 pointer-events-none" aria-hidden="true" /> | |
| <Input | |
| placeholder="Search by code, type, brand, model, or serial number..." | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| className="pl-10" | |
| aria-label="Search assets by code, type, brand, model, or serial number" | |
| type="search" | |
| /> | |
| </div> | |
| </div> | |
| <div className="flex flex-col sm:flex-row gap-4"> | |
| <div className="w-full sm:w-48"> | |
| <label htmlFor="type-filter" className="sr-only">Filter by asset type</label> | |
| <CustomDropdown | |
| options={[ | |
| { value: 'all', label: 'All Types' }, | |
| ...ASSET_TYPES.map(type => ({ value: type, label: type })) | |
| ]} | |
| value={typeFilter} | |
| onChange={setTypeFilter} | |
| placeholder="Filter by type" | |
| className="w-full" | |
| aria-label="Filter by asset type" | |
| /> | |
| </div> | |
| <div className="w-full sm:w-48"> | |
| <label htmlFor="condition-filter" className="sr-only">Filter by asset condition</label> | |
| <CustomDropdown | |
| options={[ | |
| { value: 'all', label: 'All Conditions' }, | |
| ...ASSET_CONDITIONS.map(condition => ({ value: condition, label: condition })) | |
| ]} | |
| value={conditionFilter} | |
| onChange={setConditionFilter} | |
| placeholder="Filter by condition" | |
| className="w-full" | |
| aria-label="Filter by asset condition" | |
| /> | |
| </div> | |
| <div className="w-full sm:w-48"> | |
| <label htmlFor="assignment-filter" className="sr-only">Filter by assignment status</label> | |
| <CustomDropdown | |
| options={[ | |
| { value: 'all', label: 'All Assets' }, | |
| { value: 'assigned', label: 'Assigned' }, | |
| { value: 'unassigned', label: 'Unassigned' } | |
| ]} | |
| value={assignmentStatusFilter} | |
| onChange={setAssignmentStatusFilter} | |
| placeholder="Filter by assignment" | |
| className="w-full" | |
| aria-label="Filter by assignment status" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Results count */} | |
| <div className="text-sm text-muted-foreground" aria-live="polite" aria-atomic="true"> | |
| Showing {paginatedAssets.length} of {filteredAndSortedAssets.length} assets | |
| {filteredAndSortedAssets.length !== assets.length && ( | |
| <span> (filtered from {assets.length} total)</span> | |
| )} | |
| </div> | |
| {/* Table View - Desktop */} | |
| {viewMode === 'table' && ( | |
| <div className="hidden md:block border rounded-lg"> | |
| <Table> | |
| <TableHeader> | |
| <TableRow> | |
| <TableHead | |
| className="cursor-pointer hover:bg-muted/50 focus:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" | |
| onClick={() => handleSort('assetCode')} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| handleSort('assetCode'); | |
| } | |
| }} | |
| tabIndex={0} | |
| role="button" | |
| aria-label={`Sort by Code ${sortConfig.field === 'assetCode' ? (sortConfig.direction === 'asc' ? 'descending' : 'ascending') : 'ascending'}`} | |
| > | |
| <div className="flex items-center gap-2"> | |
| Code | |
| {getSortIcon('assetCode')} | |
| </div> | |
| </TableHead> | |
| <TableHead | |
| className="cursor-pointer hover:bg-muted/50 focus:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" | |
| onClick={() => handleSort('assetType')} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| handleSort('assetType'); | |
| } | |
| }} | |
| tabIndex={0} | |
| role="button" | |
| aria-label={`Sort by Type ${sortConfig.field === 'assetType' ? (sortConfig.direction === 'asc' ? 'descending' : 'ascending') : 'ascending'}`} | |
| > | |
| <div className="flex items-center gap-2"> | |
| Type | |
| {getSortIcon('assetType')} | |
| </div> | |
| </TableHead> | |
| <TableHead | |
| className="cursor-pointer hover:bg-muted/50 focus:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" | |
| onClick={() => handleSort('brandModel')} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| handleSort('brandModel'); | |
| } | |
| }} | |
| tabIndex={0} | |
| role="button" | |
| aria-label={`Sort by Brand/Model ${sortConfig.field === 'brandModel' ? (sortConfig.direction === 'asc' ? 'descending' : 'ascending') : 'ascending'}`} | |
| > | |
| <div className="flex items-center gap-2"> | |
| Brand/Model | |
| {getSortIcon('brandModel')} | |
| </div> | |
| </TableHead> | |
| <TableHead | |
| className="cursor-pointer hover:bg-muted/50 focus:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" | |
| onClick={() => handleSort('serialNumber')} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| handleSort('serialNumber'); | |
| } | |
| }} | |
| tabIndex={0} | |
| role="button" | |
| aria-label={`Sort by Serial Number ${sortConfig.field === 'serialNumber' ? (sortConfig.direction === 'asc' ? 'descending' : 'ascending') : 'ascending'}`} | |
| > | |
| <div className="flex items-center gap-2"> | |
| Serial Number | |
| {getSortIcon('serialNumber')} | |
| </div> | |
| </TableHead> | |
| <TableHead | |
| className="cursor-pointer hover:bg-muted/50 focus:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" | |
| onClick={() => handleSort('assetCondition')} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| handleSort('assetCondition'); | |
| } | |
| }} | |
| tabIndex={0} | |
| role="button" | |
| aria-label={`Sort by Condition ${sortConfig.field === 'assetCondition' ? (sortConfig.direction === 'asc' ? 'descending' : 'ascending') : 'ascending'}`} | |
| > | |
| <div className="flex items-center gap-2"> | |
| Condition | |
| {getSortIcon('assetCondition')} | |
| </div> | |
| </TableHead> | |
| <TableHead>Status</TableHead> | |
| {showActions && <TableHead className="text-right">Actions</TableHead>} | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {paginatedAssets.length === 0 ? ( | |
| <TableRow> | |
| <TableCell | |
| colSpan={showActions ? 7 : 6} | |
| className="text-center py-8 text-muted-foreground" | |
| > | |
| {assets.length === 0 | |
| ? "No assets found. Create your first asset to get started." | |
| : "No assets match your current filters." | |
| } | |
| </TableCell> | |
| </TableRow> | |
| ) : ( | |
| paginatedAssets.map((asset) => { | |
| const activeAssignment = getActiveAssignment(asset.assetID); | |
| const isAvailable = isAssetAvailable(asset.assetID); | |
| return ( | |
| <TableRow key={asset.assetID} className="hover:bg-muted/50"> | |
| <TableCell className="font-medium">{asset.assetCode}</TableCell> | |
| <TableCell>{asset.assetType}</TableCell> | |
| <TableCell>{asset.brandModel}</TableCell> | |
| <TableCell className="font-mono text-sm">{asset.serialNumber}</TableCell> | |
| <TableCell> | |
| <Badge variant={getConditionBadgeVariant(asset.assetCondition as AssetCondition)}> | |
| {asset.assetCondition} | |
| </Badge> | |
| </TableCell> | |
| <TableCell> | |
| {activeAssignment ? ( | |
| <Badge variant="secondary" className="flex items-center gap-1 w-fit"> | |
| <User className="h-3 w-3" /> | |
| <span className="text-xs">Assigned to {activeAssignment.employeeName}</span> | |
| </Badge> | |
| ) : ( | |
| <Badge variant="outline" className="flex items-center gap-1 w-fit text-green-600 border-green-600"> | |
| <span className="text-xs">Available</span> | |
| </Badge> | |
| )} | |
| </TableCell> | |
| {showActions && ( | |
| <TableCell className="text-right"> | |
| <div className="flex justify-end gap-2"> | |
| {onAssign && isAvailable && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => onAssign(asset)} | |
| className="h-8 w-8 p-0" | |
| aria-label={`Assign asset ${asset.assetCode} to employee`} | |
| title="Assign to Employee" | |
| > | |
| <UserPlus className="h-4 w-4" /> | |
| <span className="sr-only">Assign asset {asset.assetCode}</span> | |
| </Button> | |
| )} | |
| {onView && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => onView(asset)} | |
| className="h-8 w-8 p-0" | |
| aria-label={`View asset ${asset.brandModel} (${asset.serialNumber})`} | |
| > | |
| <Eye className="h-4 w-4" /> | |
| <span className="sr-only">View asset {asset.brandModel}</span> | |
| </Button> | |
| )} | |
| {onEdit && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => onEdit(asset)} | |
| className="h-8 w-8 p-0" | |
| aria-label={`Edit asset ${asset.brandModel} (${asset.serialNumber})`} | |
| > | |
| <Edit className="h-4 w-4" /> | |
| <span className="sr-only">Edit asset {asset.brandModel}</span> | |
| </Button> | |
| )} | |
| {onDelete && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => onDelete(asset)} | |
| className="h-8 w-8 p-0 text-destructive hover:text-destructive" | |
| aria-label={`Delete asset ${asset.brandModel} (${asset.serialNumber})`} | |
| > | |
| <Trash2 className="h-4 w-4" /> | |
| <span className="sr-only">Delete asset {asset.brandModel}</span> | |
| </Button> | |
| )} | |
| </div> | |
| </TableCell> | |
| )} | |
| </TableRow> | |
| ); | |
| }) | |
| )} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| )} | |
| {/* Card View - Desktop */} | |
| {viewMode === 'cards' && ( | |
| <div className="hidden md:block"> | |
| {paginatedAssets.length === 0 ? ( | |
| <div className="text-center py-8 text-muted-foreground"> | |
| {assets.length === 0 | |
| ? "No assets found. Create your first asset to get started." | |
| : "No assets match your current filters." | |
| } | |
| </div> | |
| ) : ( | |
| <div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6"> | |
| {paginatedAssets.map((asset) => ( | |
| <AssetCard | |
| key={asset.assetID} | |
| asset={asset} | |
| onView={onView} | |
| onEdit={onEdit} | |
| onDelete={onDelete} | |
| onAssign={onAssign} | |
| activeAssignment={getActiveAssignment(asset.assetID)} | |
| showActions={showActions} | |
| compact={true} | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* Mobile Card View */} | |
| <div className="md:hidden space-y-6"> | |
| {paginatedAssets.length === 0 ? ( | |
| <div className="text-center py-8 text-muted-foreground"> | |
| {assets.length === 0 | |
| ? "No assets found. Create your first asset to get started." | |
| : "No assets match your current filters." | |
| } | |
| </div> | |
| ) : ( | |
| paginatedAssets.map((asset) => ( | |
| <AssetCard | |
| key={asset.assetID} | |
| asset={asset} | |
| onView={onView} | |
| onEdit={onEdit} | |
| onDelete={onDelete} | |
| onAssign={onAssign} | |
| activeAssignment={getActiveAssignment(asset.assetID)} | |
| showActions={showActions} | |
| compact={true} | |
| /> | |
| )) | |
| )} | |
| </div> | |
| {/* Pagination */} | |
| {filteredAndSortedAssets.length > 0 && ( | |
| <Pagination | |
| currentPage={currentPage} | |
| totalPages={totalPages} | |
| totalItems={filteredAndSortedAssets.length} | |
| itemsPerPage={itemsPerPage} | |
| onPageChange={setCurrentPage} | |
| onItemsPerPageChange={setItemsPerPage} | |
| className="mt-4" | |
| /> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default AssetList; |