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 { Card, CardContent } from '@/components/ui/card'; | |
| import { CustomDropdown } from './shared/CustomDropdown'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { | |
| Search, | |
| Eye, | |
| Edit, | |
| Trash2, | |
| Plus, | |
| ArrowUpDown, | |
| ArrowUp, | |
| ArrowDown, | |
| User, | |
| Calendar, | |
| CheckCircle, | |
| Clock | |
| } from 'lucide-react'; | |
| import { AssetAssignment, AssetAssignmentWithDetails } from '@/types/asset-management'; | |
| import { format, isValid, subDays, subMonths, subYears } from 'date-fns'; | |
| import { LoadingSpinner } from './shared'; | |
| import Pagination from './shared/Pagination'; | |
| import { usePagination } from '@/hooks/asset-management/usePagination'; | |
| import { useIsMobile } from '@/hooks/use-mobile'; | |
| interface AssignmentListProps { | |
| assignments: AssetAssignment[] | AssetAssignmentWithDetails[]; | |
| isLoading?: boolean; | |
| onView?: (assignment: AssetAssignment) => void; | |
| onEdit?: (assignment: AssetAssignment) => void; | |
| onDelete?: (assignment: AssetAssignment) => void; | |
| onCreate?: () => void; | |
| onNavigateToAsset?: (assetId: number) => void; | |
| onNavigateToEmployee?: (employeeId: number) => void; | |
| showActions?: boolean; | |
| showAssetInfo?: boolean; | |
| } | |
| type SortField = keyof AssetAssignment; | |
| type SortDirection = 'asc' | 'desc'; | |
| interface SortConfig { | |
| field: SortField; | |
| direction: SortDirection; | |
| } | |
| const AssignmentList: React.FC<AssignmentListProps> = ({ | |
| assignments, | |
| isLoading = false, | |
| onView, | |
| onEdit, | |
| onDelete, | |
| onCreate, | |
| onNavigateToAsset, | |
| onNavigateToEmployee, | |
| showActions = true, | |
| showAssetInfo = true | |
| }) => { | |
| const isMobile = useIsMobile(); | |
| const [searchTerm, setSearchTerm] = useState(''); | |
| const [statusFilter, setStatusFilter] = useState<string>('all'); | |
| const [dateRangeFilter, setDateRangeFilter] = useState<string>('all'); | |
| const [sortConfig, setSortConfig] = useState<SortConfig>({ field: 'assignmentID', direction: 'desc' }); | |
| // Helper function to determine assignment status | |
| const getAssignmentStatus = (assignment: AssetAssignment) => { | |
| return assignment.returnedDate ? 'returned' : 'active'; | |
| }; | |
| // Helper function to get status badge variant | |
| const getStatusBadgeVariant = (status: string) => { | |
| switch (status) { | |
| case 'active': | |
| return 'default'; | |
| case 'returned': | |
| return 'secondary'; | |
| default: | |
| return 'outline'; | |
| } | |
| }; | |
| // Helper function to format date safely | |
| const formatDate = (dateString: string | null) => { | |
| if (!dateString) return 'N/A'; | |
| const date = new Date(dateString); | |
| return isValid(date) ? format(date, 'PP') : 'Invalid Date'; | |
| }; | |
| // Helper function to check if assignment matches date range filter | |
| const matchesDateRange = (assignment: AssetAssignment) => { | |
| if (dateRangeFilter === 'all') return true; | |
| const assignedDate = new Date(assignment.assignedDate); | |
| const now = new Date(); | |
| switch (dateRangeFilter) { | |
| case 'last7days': | |
| return assignedDate >= subDays(now, 7); | |
| case 'last30days': | |
| return assignedDate >= subDays(now, 30); | |
| case 'last3months': | |
| return assignedDate >= subMonths(now, 3); | |
| case 'lastyear': | |
| return assignedDate >= subYears(now, 1); | |
| default: | |
| return true; | |
| } | |
| }; | |
| // Filter and sort assignments | |
| const filteredAndSortedAssignments = useMemo(() => { | |
| let filtered = assignments.filter(assignment => { | |
| const assignmentWithDetails = assignment as AssetAssignmentWithDetails; | |
| const status = getAssignmentStatus(assignment); | |
| const matchesSearch = | |
| assignment.employeeID.toString().includes(searchTerm) || | |
| assignment.assetID.toString().includes(searchTerm) || | |
| (assignment.assetCode && assignment.assetCode.toLowerCase().includes(searchTerm.toLowerCase())) || | |
| assignment.remarks.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| (assignment.employeeName && assignment.employeeName.toLowerCase().includes(searchTerm.toLowerCase())) || | |
| (assignmentWithDetails.asset && | |
| (assignmentWithDetails.asset.assetCode.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| assignmentWithDetails.asset.assetType.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| assignmentWithDetails.asset.brandModel.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| assignmentWithDetails.asset.serialNumber.toLowerCase().includes(searchTerm.toLowerCase()))); | |
| const matchesStatus = statusFilter === 'all' || status === statusFilter; | |
| const matchesDateRangeFilter = matchesDateRange(assignment); | |
| return matchesSearch && matchesStatus && matchesDateRangeFilter; | |
| }); | |
| // Sort assignments | |
| 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; | |
| } | |
| // Handle date sorting | |
| if (sortConfig.field === 'assignedDate' || sortConfig.field === 'returnedDate') { | |
| const aDate = new Date(aValue as string); | |
| const bDate = new Date(bValue as string); | |
| const comparison = aDate.getTime() - bDate.getTime(); | |
| return sortConfig.direction === 'asc' ? comparison : -comparison; | |
| } | |
| return 0; | |
| }); | |
| return filtered; | |
| }, [assignments, searchTerm, statusFilter, dateRangeFilter, sortConfig]); | |
| // Use pagination hook | |
| const { | |
| currentPage, | |
| itemsPerPage, | |
| totalPages, | |
| paginatedData: paginatedAssignments, | |
| setCurrentPage, | |
| setItemsPerPage, | |
| } = usePagination(filteredAndSortedAssignments); | |
| 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" />; | |
| }; | |
| // Mobile Card Component | |
| const AssignmentCard: React.FC<{ assignment: AssetAssignment }> = ({ assignment }) => { | |
| const assignmentWithDetails = assignment as AssetAssignmentWithDetails; | |
| const status = getAssignmentStatus(assignment); | |
| return ( | |
| <Card className="mb-3 hover:shadow-md transition-shadow"> | |
| <CardContent className="p-4"> | |
| <div className="flex justify-between items-start mb-3"> | |
| <div className="flex items-center gap-2"> | |
| <Badge variant="outline" className="text-xs"> | |
| ID #{assignment.assignmentID} | |
| </Badge> | |
| <Badge variant={getStatusBadgeVariant(status)} className="text-xs"> | |
| {status === 'active' ? ( | |
| <> | |
| <Clock className="h-3 w-3 mr-1" /> | |
| Active | |
| </> | |
| ) : ( | |
| <> | |
| <CheckCircle className="h-3 w-3 mr-1" /> | |
| Returned | |
| </> | |
| )} | |
| </Badge> | |
| </div> | |
| {showActions && ( | |
| <div className="flex gap-1"> | |
| {onView && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => onView(assignment)} | |
| className="h-8 w-8 p-0" | |
| > | |
| <Eye className="h-4 w-4" /> | |
| </Button> | |
| )} | |
| {onEdit && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => onEdit(assignment)} | |
| className="h-8 w-8 p-0" | |
| > | |
| <Edit className="h-4 w-4" /> | |
| </Button> | |
| )} | |
| {onDelete && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => onDelete(assignment)} | |
| className="h-8 w-8 p-0 text-destructive hover:text-destructive" | |
| > | |
| <Trash2 className="h-4 w-4" /> | |
| </Button> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| {/* Employee Info */} | |
| <div className="flex items-center gap-2 mb-2"> | |
| <User className="h-4 w-4 text-muted-foreground flex-shrink-0" /> | |
| <div className="min-w-0 flex-1"> | |
| <div className="font-medium text-sm truncate"> | |
| {assignment.employeeName || `Employee #${assignment.employeeID}`} | |
| </div> | |
| {assignmentWithDetails.employeeEmail && ( | |
| <div className="text-xs text-muted-foreground truncate"> | |
| {assignmentWithDetails.employeeEmail} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Asset Info */} | |
| {showAssetInfo && ( | |
| <div className="flex items-center gap-2 mb-2"> | |
| <Badge variant="outline" className="text-xs flex-shrink-0"> | |
| {assignment.assetCode || `Asset #${assignment.assetID}`} | |
| </Badge> | |
| <div className="min-w-0 flex-1"> | |
| {assignmentWithDetails.asset ? ( | |
| <> | |
| <div className="font-medium text-sm truncate"> | |
| {assignmentWithDetails.asset.assetType} | |
| </div> | |
| <div className="text-xs text-muted-foreground truncate"> | |
| {assignmentWithDetails.asset.brandModel} | |
| </div> | |
| </> | |
| ) : ( | |
| <div className="font-medium text-sm truncate"> | |
| {assignment.assetCode} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* Dates */} | |
| <div className="space-y-1 mb-2"> | |
| <div className="flex items-center gap-2 text-sm"> | |
| <Calendar className="h-3 w-3 text-muted-foreground" /> | |
| <span className="text-muted-foreground">Assigned:</span> | |
| <span className="font-medium">{formatDate(assignment.assignedDate)}</span> | |
| </div> | |
| {assignment.returnedDate && ( | |
| <div className="flex items-center gap-2 text-sm"> | |
| <Calendar className="h-3 w-3 text-muted-foreground" /> | |
| <span className="text-muted-foreground">Returned:</span> | |
| <span className="font-medium">{formatDate(assignment.returnedDate)}</span> | |
| </div> | |
| )} | |
| </div> | |
| {/* Remarks */} | |
| {assignment.remarks && ( | |
| <div className="text-sm text-muted-foreground"> | |
| <span className="font-medium">Remarks:</span> {assignment.remarks} | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| ); | |
| }; | |
| if (isLoading) { | |
| return ( | |
| <div className="space-y-4"> | |
| <div className="flex justify-between items-center"> | |
| <h2 className="text-2xl font-bold">Asset Assignments</h2> | |
| </div> | |
| <LoadingSpinner size="lg" text="Loading assignments..." className="py-8" /> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="space-y-4"> | |
| {/* Header */} | |
| <div className="flex justify-between items-center"> | |
| <h2 className="text-2xl font-bold">Asset Assignments</h2> | |
| {onCreate && ( | |
| <Button onClick={onCreate} className="flex items-center gap-2"> | |
| <Plus className="h-4 w-4" /> | |
| New Assignment | |
| </Button> | |
| )} | |
| </div> | |
| {/* Filters */} | |
| <div className="flex flex-col gap-4"> | |
| <div className="relative"> | |
| <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" /> | |
| <Input | |
| placeholder="Search by employee, asset code, asset type, or remarks..." | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| className="pl-10" | |
| /> | |
| </div> | |
| <div className="flex flex-col sm:flex-row gap-4"> | |
| <CustomDropdown | |
| options={[ | |
| { value: 'all', label: 'All Assignments' }, | |
| { value: 'active', label: 'Active' }, | |
| { value: 'returned', label: 'Returned' } | |
| ]} | |
| value={statusFilter} | |
| onChange={setStatusFilter} | |
| placeholder="Filter by status" | |
| className="w-full sm:w-48" | |
| aria-label="Filter by assignment status" | |
| /> | |
| <CustomDropdown | |
| options={[ | |
| { value: 'all', label: 'All Time' }, | |
| { value: 'last7days', label: 'Last 7 Days' }, | |
| { value: 'last30days', label: 'Last 30 Days' }, | |
| { value: 'last3months', label: 'Last 3 Months' }, | |
| { value: 'lastyear', label: 'Last Year' } | |
| ]} | |
| value={dateRangeFilter} | |
| onChange={setDateRangeFilter} | |
| placeholder="Filter by date range" | |
| className="w-full sm:w-48" | |
| aria-label="Filter by date range" | |
| /> | |
| </div> | |
| </div> | |
| {/* Results count */} | |
| <div className="text-sm text-muted-foreground"> | |
| Showing {paginatedAssignments.length} of {filteredAndSortedAssignments.length} assignments | |
| {filteredAndSortedAssignments.length !== assignments.length && ( | |
| <span> (filtered from {assignments.length} total)</span> | |
| )} | |
| </div> | |
| {/* Mobile Card View / Desktop Table View */} | |
| {isMobile ? ( | |
| // Mobile Card Layout | |
| <div className="space-y-3"> | |
| {paginatedAssignments.length === 0 ? ( | |
| <Card> | |
| <CardContent className="p-6 text-center text-muted-foreground"> | |
| {assignments.length === 0 | |
| ? "No assignments found. Create your first assignment to get started." | |
| : "No assignments match your current filters." | |
| } | |
| </CardContent> | |
| </Card> | |
| ) : ( | |
| paginatedAssignments.map((assignment) => ( | |
| <AssignmentCard key={assignment.assignmentID} assignment={assignment} /> | |
| )) | |
| )} | |
| </div> | |
| ) : ( | |
| // Desktop Table Layout | |
| <div className="border rounded-lg overflow-hidden"> | |
| <Table> | |
| <TableHeader> | |
| <TableRow> | |
| <TableHead | |
| className="cursor-pointer hover:bg-muted/50" | |
| onClick={() => handleSort('assignmentID')} | |
| > | |
| <div className="flex items-center gap-2"> | |
| ID | |
| {getSortIcon('assignmentID')} | |
| </div> | |
| </TableHead> | |
| {showAssetInfo && ( | |
| <TableHead | |
| className="cursor-pointer hover:bg-muted/50" | |
| onClick={() => handleSort('assetCode')} | |
| > | |
| <div className="flex items-center gap-2"> | |
| Asset | |
| {getSortIcon('assetCode')} | |
| </div> | |
| </TableHead> | |
| )} | |
| <TableHead | |
| className="cursor-pointer hover:bg-muted/50" | |
| onClick={() => handleSort('employeeID')} | |
| > | |
| <div className="flex items-center gap-2"> | |
| Employee | |
| {getSortIcon('employeeID')} | |
| </div> | |
| </TableHead> | |
| <TableHead | |
| className="cursor-pointer hover:bg-muted/50" | |
| onClick={() => handleSort('assignedDate')} | |
| > | |
| <div className="flex items-center gap-2"> | |
| Assigned Date | |
| {getSortIcon('assignedDate')} | |
| </div> | |
| </TableHead> | |
| <TableHead | |
| className="cursor-pointer hover:bg-muted/50" | |
| onClick={() => handleSort('returnedDate')} | |
| > | |
| <div className="flex items-center gap-2"> | |
| Returned Date | |
| {getSortIcon('returnedDate')} | |
| </div> | |
| </TableHead> | |
| <TableHead>Status</TableHead> | |
| <TableHead>Remarks</TableHead> | |
| {showActions && <TableHead className="text-right">Actions</TableHead>} | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {paginatedAssignments.length === 0 ? ( | |
| <TableRow> | |
| <TableCell | |
| colSpan={showActions ? (showAssetInfo ? 8 : 7) : (showAssetInfo ? 7 : 6)} | |
| className="text-center py-8 text-muted-foreground" | |
| > | |
| {assignments.length === 0 | |
| ? "No assignments found. Create your first assignment to get started." | |
| : "No assignments match your current filters." | |
| } | |
| </TableCell> | |
| </TableRow> | |
| ) : ( | |
| paginatedAssignments.map((assignment) => { | |
| const assignmentWithDetails = assignment as AssetAssignmentWithDetails; | |
| const status = getAssignmentStatus(assignment); | |
| return ( | |
| <TableRow key={assignment.assignmentID} className="hover:bg-muted/50"> | |
| <TableCell className="font-medium">{assignment.assignmentID}</TableCell> | |
| {showAssetInfo && ( | |
| <TableCell> | |
| <div className="flex items-center gap-2"> | |
| {/* <Badge variant="outline">{assignment.assetCode || `#${assignment.assetID}`}</Badge> */} | |
| {assignmentWithDetails.asset ? ( | |
| <div className="text-sm"> | |
| <div className="font-medium">{assignmentWithDetails.asset.assetType}</div> | |
| <div className="text-muted-foreground text-xs"> | |
| {assignmentWithDetails.asset.brandModel} | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="text-sm"> | |
| <div className="font-medium">{assignment.assetCode}</div> | |
| </div> | |
| )} | |
| </div> | |
| </TableCell> | |
| )} | |
| <TableCell> | |
| <div className="flex items-center gap-2"> | |
| <User className="h-4 w-4 text-muted-foreground" /> | |
| <div> | |
| <div className="font-medium"> | |
| {assignment.employeeName || `Employee #${assignment.employeeID}`} | |
| </div> | |
| {assignmentWithDetails.employeeEmail && ( | |
| <div className="text-xs text-muted-foreground"> | |
| {assignmentWithDetails.employeeEmail} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </TableCell> | |
| <TableCell> | |
| <div className="flex items-center gap-2"> | |
| <Calendar className="h-4 w-4 text-muted-foreground" /> | |
| {formatDate(assignment.assignedDate)} | |
| </div> | |
| </TableCell> | |
| <TableCell> | |
| {assignment.returnedDate ? ( | |
| <div className="flex items-center gap-2"> | |
| <Calendar className="h-4 w-4 text-muted-foreground" /> | |
| {formatDate(assignment.returnedDate)} | |
| </div> | |
| ) : ( | |
| <span className="text-muted-foreground">-</span> | |
| )} | |
| </TableCell> | |
| <TableCell> | |
| <Badge variant={getStatusBadgeVariant(status)}> | |
| {status === 'active' ? ( | |
| <> | |
| <Clock className="h-3 w-3 mr-1" /> | |
| Active | |
| </> | |
| ) : ( | |
| <> | |
| <CheckCircle className="h-3 w-3 mr-1" /> | |
| Returned | |
| </> | |
| )} | |
| </Badge> | |
| </TableCell> | |
| <TableCell className="max-w-48 truncate" title={assignment.remarks}> | |
| {assignment.remarks} | |
| </TableCell> | |
| {showActions && ( | |
| <TableCell className="text-right"> | |
| <div className="flex justify-end gap-2"> | |
| {onView && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => onView(assignment)} | |
| className="h-8 w-8 p-0" | |
| > | |
| <Eye className="h-4 w-4" /> | |
| <span className="sr-only">View assignment</span> | |
| </Button> | |
| )} | |
| {onEdit && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => onEdit(assignment)} | |
| className="h-8 w-8 p-0" | |
| > | |
| <Edit className="h-4 w-4" /> | |
| <span className="sr-only">Edit assignment</span> | |
| </Button> | |
| )} | |
| {onDelete && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => onDelete(assignment)} | |
| className="h-8 w-8 p-0 text-destructive hover:text-destructive" | |
| > | |
| <Trash2 className="h-4 w-4" /> | |
| <span className="sr-only">Delete assignment</span> | |
| </Button> | |
| )} | |
| </div> | |
| </TableCell> | |
| )} | |
| </TableRow> | |
| ); | |
| }) | |
| )} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| )} | |
| {/* Pagination */} | |
| {filteredAndSortedAssignments.length > 0 && ( | |
| <Pagination | |
| currentPage={currentPage} | |
| totalPages={totalPages} | |
| totalItems={filteredAndSortedAssignments.length} | |
| itemsPerPage={itemsPerPage} | |
| onPageChange={setCurrentPage} | |
| onItemsPerPageChange={setItemsPerPage} | |
| className="mt-4" | |
| /> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default AssignmentList; |