Spaces:
Sleeping
Sleeping
| import React, { useState, useMemo } from 'react'; | |
| import { format } from 'date-fns'; | |
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Input } from '@/components/ui/input'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from '@/components/ui/select'; | |
| import { | |
| Table, | |
| TableBody, | |
| TableCell, | |
| TableHead, | |
| TableHeader, | |
| TableRow, | |
| } from '@/components/ui/table'; | |
| import { | |
| CheckCircle, | |
| XCircle, | |
| Search, | |
| User, | |
| Package, | |
| Loader2, | |
| ChevronDown, | |
| ChevronUp, | |
| AlertTriangle, | |
| } from 'lucide-react'; | |
| import { | |
| AssetRequestWithAvailability, | |
| AssetRequestApprovalRequest, | |
| AssetRequestRejectionRequest, | |
| } from '@/types/asset-request'; | |
| import ActionDialog from './ActionDialog'; | |
| import ViewDetailsDialog from './ViewDetailsDialog'; | |
| import CommentDialog from './CommentDialog'; | |
| interface AssetRequestReviewProps { | |
| requests: AssetRequestWithAvailability[]; | |
| isLoading?: boolean; | |
| onApprove: (id: number, data: AssetRequestApprovalRequest) => Promise<void>; | |
| onReject: (id: number, data: AssetRequestRejectionRequest) => Promise<void>; | |
| onAddComment?: (id: number, comments: string) => Promise<void>; | |
| currentUserId: number; | |
| } | |
| type SortField = 'date' | 'employee' | 'assetType'; | |
| type SortOrder = 'asc' | 'desc'; | |
| const AssetRequestReview: React.FC<AssetRequestReviewProps> = ({ | |
| requests, | |
| isLoading = false, | |
| onApprove, | |
| onReject, | |
| onAddComment, | |
| currentUserId, | |
| }) => { | |
| const [searchTerm, setSearchTerm] = useState(''); | |
| const [sortField, setSortField] = useState<SortField>('date'); | |
| const [sortOrder, setSortOrder] = useState<SortOrder>('desc'); | |
| const [selectedRequest, setSelectedRequest] = useState<AssetRequestWithAvailability | null>(null); | |
| const [showDetailDialog, setShowDetailDialog] = useState(false); | |
| const [showApproveDialog, setShowApproveDialog] = useState(false); | |
| const [showRejectDialog, setShowRejectDialog] = useState(false); | |
| const [showCommentDialog, setShowCommentDialog] = useState(false); | |
| // Filter and sort requests | |
| const filteredAndSortedRequests = useMemo(() => { | |
| let filtered = [...requests]; | |
| // Apply search filter | |
| if (searchTerm) { | |
| const term = searchTerm.toLowerCase(); | |
| filtered = filtered.filter( | |
| (req) => | |
| req.requestID.toString().includes(term) || | |
| req.employeeName.toLowerCase().includes(term) || | |
| req.justification.toLowerCase().includes(term) || | |
| req.requestedAssets.some((asset) => | |
| asset.assetType.toLowerCase().includes(term) | |
| ) | |
| ); | |
| } | |
| // Apply sorting | |
| filtered.sort((a, b) => { | |
| let comparison = 0; | |
| switch (sortField) { | |
| case 'date': | |
| comparison = | |
| new Date(a.submittedDate).getTime() - new Date(b.submittedDate).getTime(); | |
| break; | |
| case 'employee': | |
| comparison = a.employeeName.localeCompare(b.employeeName); | |
| break; | |
| case 'assetType': | |
| const aTypes = a.requestedAssets.map((asset) => asset.assetType).join(', '); | |
| const bTypes = b.requestedAssets.map((asset) => asset.assetType).join(', '); | |
| comparison = aTypes.localeCompare(bTypes); | |
| break; | |
| } | |
| return sortOrder === 'asc' ? comparison : -comparison; | |
| }); | |
| return filtered; | |
| }, [requests, searchTerm, sortField, sortOrder]); | |
| const handleSort = (field: SortField) => { | |
| if (sortField === field) { | |
| setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); | |
| } else { | |
| setSortField(field); | |
| setSortOrder('asc'); | |
| } | |
| }; | |
| const handleViewDetails = (request: AssetRequestWithAvailability) => { | |
| setSelectedRequest(request); | |
| setShowDetailDialog(true); | |
| }; | |
| const handleApproveClick = (request: AssetRequestWithAvailability) => { | |
| setSelectedRequest(request); | |
| // Use setTimeout to ensure state is set before opening dialog | |
| setTimeout(() => setShowApproveDialog(true), 0); | |
| }; | |
| const handleRejectClick = (request: AssetRequestWithAvailability) => { | |
| setSelectedRequest(request); | |
| // Use setTimeout to ensure state is set before opening dialog | |
| setTimeout(() => setShowRejectDialog(true), 0); | |
| }; | |
| const handleAddCommentClick = (request: AssetRequestWithAvailability) => { | |
| setSelectedRequest(request); | |
| setTimeout(() => setShowCommentDialog(true), 0); | |
| }; | |
| const handleApproveConfirm = async (comments: string) => { | |
| if (!selectedRequest) return; | |
| await onApprove(selectedRequest.requestID, { | |
| adminComments: comments, | |
| reviewedBy: currentUserId, | |
| }); | |
| }; | |
| const handleRejectConfirm = async (comments: string) => { | |
| if (!selectedRequest) return; | |
| await onReject(selectedRequest.requestID, { | |
| adminComments: comments, | |
| reviewedBy: currentUserId, | |
| }); | |
| }; | |
| const handleCommentConfirm = async (comments: string) => { | |
| if (!selectedRequest || !onAddComment) return; | |
| await onAddComment(selectedRequest.requestID, comments); | |
| }; | |
| const handleApproveDialogClose = () => { | |
| setShowApproveDialog(false); | |
| setSelectedRequest(null); | |
| }; | |
| const handleRejectDialogClose = () => { | |
| setShowRejectDialog(false); | |
| setSelectedRequest(null); | |
| }; | |
| const handleCommentDialogClose = () => { | |
| setShowCommentDialog(false); | |
| setSelectedRequest(null); | |
| }; | |
| const getAvailabilityBadge = (available: number, requested: number, showWarning: boolean = false) => { | |
| if (available >= requested) { | |
| return ( | |
| <div className="flex items-center gap-1"> | |
| <Badge className="bg-green-100 text-green-800 border-green-200"> | |
| Available ({available}) | |
| </Badge> | |
| {showWarning && available < requested * 2 && ( | |
| <div className="relative group"> | |
| <AlertTriangle className="h-4 w-4 text-yellow-600" /> | |
| <span className="absolute hidden group-hover:block bg-gray-900 text-white text-xs rounded px-2 py-1 -top-8 left-1/2 transform -translate-x-1/2 whitespace-nowrap"> | |
| Low stock warning | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } else if (available > 0) { | |
| return ( | |
| <Badge className="bg-yellow-100 text-yellow-800 border-yellow-200"> | |
| <AlertTriangle className="h-3 w-3 mr-1 inline" /> | |
| Partial ({available}/{requested}) | |
| </Badge> | |
| ); | |
| } else { | |
| return ( | |
| <Badge className="bg-red-100 text-red-800 border-red-200"> | |
| <XCircle className="h-3 w-3 mr-1 inline" /> | |
| Unavailable | |
| </Badge> | |
| ); | |
| } | |
| }; | |
| const SortIcon: React.FC<{ field: SortField }> = ({ field }) => { | |
| if (sortField !== field) return null; | |
| return sortOrder === 'asc' ? ( | |
| <ChevronUp className="h-4 w-4 inline ml-1" /> | |
| ) : ( | |
| <ChevronDown className="h-4 w-4 inline ml-1" /> | |
| ); | |
| }; | |
| if (isLoading) { | |
| return ( | |
| <Card> | |
| <CardContent className="flex items-center justify-center py-12"> | |
| <Loader2 className="h-8 w-8 animate-spin text-primary" /> | |
| </CardContent> | |
| </Card> | |
| ); | |
| } | |
| return ( | |
| <div className="space-y-4"> | |
| {/* Search and Sort Controls */} | |
| <Card> | |
| <CardHeader> | |
| <CardTitle className="text-lg">Pending Asset Requests</CardTitle> | |
| </CardHeader> | |
| <CardContent className="space-y-4"> | |
| <div className="flex flex-col sm:flex-row gap-3"> | |
| <div className="relative flex-1"> | |
| <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> | |
| <Input | |
| placeholder="Search by ID, employee, asset type, or justification..." | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| className="pl-10" | |
| /> | |
| </div> | |
| <Select | |
| value={sortField} | |
| onValueChange={(value) => setSortField(value as SortField)} | |
| > | |
| <SelectTrigger className="w-full sm:w-48"> | |
| <SelectValue placeholder="Sort by" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="date">Date</SelectItem> | |
| <SelectItem value="employee">Employee</SelectItem> | |
| <SelectItem value="assetType">Asset Type</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Requests Table */} | |
| {filteredAndSortedRequests.length === 0 ? ( | |
| <Card> | |
| <CardContent className="flex flex-col items-center justify-center py-12 text-center"> | |
| <Package className="h-12 w-12 text-muted-foreground mb-4" /> | |
| <p className="text-muted-foreground"> | |
| {searchTerm | |
| ? 'No requests match your search criteria' | |
| : 'No pending asset requests'} | |
| </p> | |
| </CardContent> | |
| </Card> | |
| ) : ( | |
| <Card> | |
| <CardContent className="p-0"> | |
| <div className="overflow-x-auto"> | |
| <Table> | |
| <TableHeader> | |
| <TableRow> | |
| <TableHead | |
| className="cursor-pointer hover:bg-muted/50" | |
| onClick={() => handleSort('date')} | |
| > | |
| Date <SortIcon field="date" /> | |
| </TableHead> | |
| <TableHead | |
| className="cursor-pointer hover:bg-muted/50" | |
| onClick={() => handleSort('employee')} | |
| > | |
| Employee <SortIcon field="employee" /> | |
| </TableHead> | |
| <TableHead | |
| className="cursor-pointer hover:bg-muted/50" | |
| onClick={() => handleSort('assetType')} | |
| > | |
| Assets <SortIcon field="assetType" /> | |
| </TableHead> | |
| <TableHead>Availability</TableHead> | |
| <TableHead className="text-right">Actions</TableHead> | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {filteredAndSortedRequests.map((request) => ( | |
| <TableRow key={request.requestID}> | |
| <TableCell> | |
| <div className="flex flex-col"> | |
| <span className="font-medium">#{request.requestID}</span> | |
| <span className="text-sm text-muted-foreground"> | |
| {format(new Date(request.submittedDate), 'MMM dd, yyyy')} | |
| </span> | |
| </div> | |
| </TableCell> | |
| <TableCell> | |
| <div className="flex items-center gap-2"> | |
| <User className="h-4 w-4 text-muted-foreground" /> | |
| <span>{request.employeeName}</span> | |
| </div> | |
| </TableCell> | |
| <TableCell> | |
| <div className="space-y-1"> | |
| {request.requestedAssets.map((asset, idx) => ( | |
| <div key={idx} className="text-sm"> | |
| {asset.assetType} (x{asset.quantity}) | |
| </div> | |
| ))} | |
| </div> | |
| </TableCell> | |
| <TableCell> | |
| <div className="space-y-1"> | |
| {request.availabilityStatus.map((status, idx) => ( | |
| <div key={idx}> | |
| {getAvailabilityBadge(status.available, status.requested, true)} | |
| </div> | |
| ))} | |
| </div> | |
| </TableCell> | |
| <TableCell className="text-right"> | |
| <div className="flex justify-end gap-2"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => handleViewDetails(request)} | |
| > | |
| View | |
| </Button> | |
| <Button | |
| variant="default" | |
| size="sm" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| handleApproveClick(request); | |
| }} | |
| className="bg-green-600 hover:bg-green-700" | |
| > | |
| <CheckCircle className="h-4 w-4 mr-1" /> | |
| Approve | |
| </Button> | |
| <Button | |
| variant="destructive" | |
| size="sm" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| handleRejectClick(request); | |
| }} | |
| > | |
| <XCircle className="h-4 w-4 mr-1" /> | |
| Reject | |
| </Button> | |
| </div> | |
| </TableCell> | |
| </TableRow> | |
| ))} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| )} | |
| {/* View Details Dialog */} | |
| <ViewDetailsDialog | |
| isOpen={showDetailDialog} | |
| onClose={() => setShowDetailDialog(false)} | |
| request={selectedRequest} | |
| onApprove={handleApproveClick} | |
| onReject={handleRejectClick} | |
| onAddComment={onAddComment ? handleAddCommentClick : undefined} | |
| /> | |
| {/* Approve Dialog */} | |
| {selectedRequest && ( | |
| <ActionDialog | |
| isOpen={showApproveDialog} | |
| onClose={handleApproveDialogClose} | |
| onConfirm={handleApproveConfirm} | |
| title="Approve Asset Request" | |
| description={`Approve request #${selectedRequest.requestID} from ${selectedRequest.employeeName}`} | |
| actionType="approve" | |
| requestId={selectedRequest.requestID} | |
| employeeName={selectedRequest.employeeName} | |
| /> | |
| )} | |
| {/* Reject Dialog */} | |
| {selectedRequest && ( | |
| <ActionDialog | |
| isOpen={showRejectDialog} | |
| onClose={handleRejectDialogClose} | |
| onConfirm={handleRejectConfirm} | |
| title="Reject Asset Request" | |
| description={`Reject request #${selectedRequest.requestID} from ${selectedRequest.employeeName}`} | |
| actionType="reject" | |
| requestId={selectedRequest.requestID} | |
| employeeName={selectedRequest.employeeName} | |
| /> | |
| )} | |
| {/* Comment Dialog */} | |
| {selectedRequest && onAddComment && ( | |
| <CommentDialog | |
| isOpen={showCommentDialog} | |
| onClose={handleCommentDialogClose} | |
| onConfirm={handleCommentConfirm} | |
| requestId={selectedRequest.requestID} | |
| employeeName={selectedRequest.employeeName} | |
| /> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default AssetRequestReview; | |