Spaces:
Sleeping
Sleeping
| import React, { useState, useMemo, useEffect } 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 { | |
| Dialog, | |
| DialogContent, | |
| DialogDescription, | |
| DialogFooter, | |
| DialogHeader, | |
| DialogTitle, | |
| } from '@/components/ui/dialog'; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from '@/components/ui/select'; | |
| import { | |
| Table, | |
| TableBody, | |
| TableCell, | |
| TableHead, | |
| TableHeader, | |
| TableRow, | |
| } from '@/components/ui/table'; | |
| import { | |
| Package, | |
| User, | |
| Calendar, | |
| CheckCircle, | |
| Loader2, | |
| AlertCircle, | |
| Search, | |
| Plus, | |
| Minus, | |
| } from 'lucide-react'; | |
| import { | |
| AssetRequest, | |
| AssetRequestAssignmentRequest, | |
| AssignedAssetDetail, | |
| } from '@/types/asset-request'; | |
| import { Asset, AssetAssignment } from '@/types/asset-management'; | |
| import { | |
| validateAssignmentRequest, | |
| calculateInventoryImpact, | |
| logInventoryChange, | |
| createInventoryAuditLog, | |
| } from '@/utils/inventoryManagement'; | |
| interface AssetRequestAssignmentProps { | |
| approvedRequests: AssetRequest[]; | |
| availableAssets: Asset[]; | |
| allAssets: Asset[]; | |
| activeAssignments: AssetAssignment[]; | |
| isLoading?: boolean; | |
| onAssign: (id: number, data: AssetRequestAssignmentRequest) => Promise<void>; | |
| currentUserId: number; | |
| } | |
| interface AssetAllocation { | |
| assetType: string; | |
| requestedQuantity: number; | |
| allocatedAssets: Asset[]; | |
| } | |
| const AssetRequestAssignment: React.FC<AssetRequestAssignmentProps> = ({ | |
| approvedRequests, | |
| availableAssets, | |
| allAssets, | |
| activeAssignments, | |
| isLoading = false, | |
| onAssign, | |
| currentUserId, | |
| }) => { | |
| const [selectedRequest, setSelectedRequest] = useState<AssetRequest | null>(null); | |
| const [showAssignmentDialog, setShowAssignmentDialog] = useState(false); | |
| const [assetAllocations, setAssetAllocations] = useState<AssetAllocation[]>([]); | |
| const [searchTerm, setSearchTerm] = useState(''); | |
| const [isSubmitting, setIsSubmitting] = useState(false); | |
| const [filterAssetType, setFilterAssetType] = useState<string>('all'); | |
| // Get unique asset types from available assets | |
| const assetTypes = useMemo(() => { | |
| const types = new Set(availableAssets.map((asset) => asset.assetType)); | |
| return Array.from(types).sort(); | |
| }, [availableAssets]); | |
| // Filter available assets based on search and type | |
| const filteredAvailableAssets = useMemo(() => { | |
| let filtered = [...availableAssets]; | |
| if (filterAssetType !== 'all') { | |
| filtered = filtered.filter((asset) => asset.assetType === filterAssetType); | |
| } | |
| if (searchTerm) { | |
| const term = searchTerm.toLowerCase(); | |
| filtered = filtered.filter( | |
| (asset) => | |
| asset.assetCode.toLowerCase().includes(term) || | |
| asset.assetType.toLowerCase().includes(term) || | |
| asset.brandModel.toLowerCase().includes(term) || | |
| asset.serialNumber.toLowerCase().includes(term) | |
| ); | |
| } | |
| return filtered; | |
| }, [availableAssets, searchTerm, filterAssetType]); | |
| // Initialize allocations when a request is selected | |
| useEffect(() => { | |
| if (selectedRequest) { | |
| const allocations: AssetAllocation[] = selectedRequest.requestedAssets.map((item) => ({ | |
| assetType: item.assetType, | |
| requestedQuantity: item.quantity, | |
| allocatedAssets: [], | |
| })); | |
| setAssetAllocations(allocations); | |
| } | |
| }, [selectedRequest]); | |
| const handleSelectRequest = (request: AssetRequest) => { | |
| setSelectedRequest(request); | |
| setShowAssignmentDialog(true); | |
| setSearchTerm(''); | |
| setFilterAssetType('all'); | |
| }; | |
| const handleAddAsset = (asset: Asset, assetType: string) => { | |
| setAssetAllocations((prev) => | |
| prev.map((allocation) => { | |
| if (allocation.assetType === assetType) { | |
| // Check if asset is already allocated | |
| if (allocation.allocatedAssets.some((a) => a.assetID === asset.assetID)) { | |
| return allocation; | |
| } | |
| // Check if we've reached the requested quantity | |
| if (allocation.allocatedAssets.length >= allocation.requestedQuantity) { | |
| return allocation; | |
| } | |
| return { | |
| ...allocation, | |
| allocatedAssets: [...allocation.allocatedAssets, asset], | |
| }; | |
| } | |
| return allocation; | |
| }) | |
| ); | |
| }; | |
| const handleRemoveAsset = (assetId: number, assetType: string) => { | |
| setAssetAllocations((prev) => | |
| prev.map((allocation) => { | |
| if (allocation.assetType === assetType) { | |
| return { | |
| ...allocation, | |
| allocatedAssets: allocation.allocatedAssets.filter( | |
| (a) => a.assetID !== assetId | |
| ), | |
| }; | |
| } | |
| return allocation; | |
| }) | |
| ); | |
| }; | |
| const handleAssignSubmit = async () => { | |
| if (!selectedRequest) return; | |
| // Validate that all requested assets have been allocated | |
| const allAllocated = assetAllocations.every( | |
| (allocation) => allocation.allocatedAssets.length === allocation.requestedQuantity | |
| ); | |
| if (!allAllocated) { | |
| return; | |
| } | |
| // Build assigned assets list | |
| const assignedAssets: AssignedAssetDetail[] = []; | |
| assetAllocations.forEach((allocation) => { | |
| allocation.allocatedAssets.forEach((asset) => { | |
| assignedAssets.push({ | |
| assetID: asset.assetID, | |
| assetCode: asset.assetCode, | |
| assetType: asset.assetType, | |
| quantity: 1, // Each asset is assigned individually | |
| }); | |
| }); | |
| }); | |
| // Validate assignment request | |
| const validation = validateAssignmentRequest(assignedAssets, allAssets, activeAssignments); | |
| if (!validation.valid) { | |
| console.error('Assignment validation failed:', validation.errors); | |
| alert(`Cannot assign assets:\n${validation.errors.join('\n')}`); | |
| return; | |
| } | |
| // Show warnings if any | |
| if (validation.warnings.length > 0) { | |
| const proceed = window.confirm( | |
| `Warning:\n${validation.warnings.join('\n')}\n\nDo you want to proceed with the assignment?` | |
| ); | |
| if (!proceed) { | |
| return; | |
| } | |
| } | |
| // Calculate inventory impact | |
| const impact = calculateInventoryImpact(assignedAssets, allAssets, activeAssignments); | |
| console.log('Inventory impact:', impact); | |
| // Log each assignment for audit trail | |
| assignedAssets.forEach((asset) => { | |
| const auditLog = createInventoryAuditLog({ | |
| action: 'assignment', | |
| assetType: asset.assetType, | |
| assetId: asset.assetID, | |
| assetCode: asset.assetCode, | |
| employeeId: selectedRequest.employeeID, | |
| employeeName: selectedRequest.employeeName, | |
| performedBy: currentUserId, | |
| previousState: 'available', | |
| newState: 'assigned', | |
| requestId: selectedRequest.requestID, | |
| remarks: `Assigned via asset request #${selectedRequest.requestID}` | |
| }); | |
| logInventoryChange(auditLog); | |
| }); | |
| setIsSubmitting(true); | |
| try { | |
| await onAssign(selectedRequest.requestID, { | |
| assignedAssets, | |
| assignedBy: currentUserId, | |
| }); | |
| console.log('Assignment completed successfully'); | |
| console.log('Updated inventory status:', impact); | |
| setShowAssignmentDialog(false); | |
| setSelectedRequest(null); | |
| setAssetAllocations([]); | |
| } catch (error) { | |
| console.error('Error assigning assets:', error); | |
| alert(`Failed to assign assets: ${error instanceof Error ? error.message : 'Unknown error'}`); | |
| } finally { | |
| setIsSubmitting(false); | |
| } | |
| }; | |
| const isAssetAllocated = (assetId: number): boolean => { | |
| return assetAllocations.some((allocation) => | |
| allocation.allocatedAssets.some((a) => a.assetID === assetId) | |
| ); | |
| }; | |
| const canAddMoreAssets = (assetType: string): boolean => { | |
| const allocation = assetAllocations.find((a) => a.assetType === assetType); | |
| if (!allocation) return false; | |
| return allocation.allocatedAssets.length < allocation.requestedQuantity; | |
| }; | |
| const getAllocationProgress = (assetType: string): { current: number; total: number } => { | |
| const allocation = assetAllocations.find((a) => a.assetType === assetType); | |
| if (!allocation) return { current: 0, total: 0 }; | |
| return { | |
| current: allocation.allocatedAssets.length, | |
| total: allocation.requestedQuantity, | |
| }; | |
| }; | |
| const isAllocationComplete = (): boolean => { | |
| return assetAllocations.every( | |
| (allocation) => allocation.allocatedAssets.length === allocation.requestedQuantity | |
| ); | |
| }; | |
| 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"> | |
| <Card> | |
| <CardHeader> | |
| <CardTitle className="text-lg">Approved Requests - Assignment Queue</CardTitle> | |
| </CardHeader> | |
| <CardContent> | |
| {approvedRequests.length === 0 ? ( | |
| <div 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"> | |
| No approved requests waiting for asset assignment | |
| </p> | |
| </div> | |
| ) : ( | |
| <div className="overflow-x-auto"> | |
| <Table> | |
| <TableHeader> | |
| <TableRow> | |
| <TableHead>Request ID</TableHead> | |
| <TableHead>Employee</TableHead> | |
| <TableHead>Requested Assets</TableHead> | |
| <TableHead>Approved Date</TableHead> | |
| <TableHead className="text-right">Actions</TableHead> | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {approvedRequests.map((request) => ( | |
| <TableRow key={request.requestID}> | |
| <TableCell className="font-medium">#{request.requestID}</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> | |
| {request.reviewedDate && ( | |
| <div className="flex items-center gap-2 text-sm"> | |
| <Calendar className="h-4 w-4 text-muted-foreground" /> | |
| <span>{format(new Date(request.reviewedDate), 'MMM dd, yyyy')}</span> | |
| </div> | |
| )} | |
| </TableCell> | |
| <TableCell className="text-right"> | |
| <Button | |
| variant="default" | |
| size="sm" | |
| onClick={() => handleSelectRequest(request)} | |
| > | |
| <Package className="h-4 w-4 mr-2" /> | |
| Assign Assets | |
| </Button> | |
| </TableCell> | |
| </TableRow> | |
| ))} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| {/* Assignment Dialog */} | |
| <Dialog open={showAssignmentDialog} onOpenChange={setShowAssignmentDialog}> | |
| <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto"> | |
| <DialogHeader> | |
| <DialogTitle>Assign Assets to Request #{selectedRequest?.requestID}</DialogTitle> | |
| <DialogDescription> | |
| Select specific assets to assign to {selectedRequest?.employeeName} | |
| </DialogDescription> | |
| </DialogHeader> | |
| {selectedRequest && ( | |
| <div className="space-y-6"> | |
| {/* Request Summary */} | |
| <Card className="border-2"> | |
| <CardHeader className="pb-3"> | |
| <CardTitle className="text-base">Request Summary</CardTitle> | |
| </CardHeader> | |
| <CardContent className="space-y-2"> | |
| <div className="grid grid-cols-2 gap-4 text-sm"> | |
| <div> | |
| <span className="text-muted-foreground">Employee:</span>{' '} | |
| <span className="font-medium">{selectedRequest.employeeName}</span> | |
| </div> | |
| <div> | |
| <span className="text-muted-foreground">Submitted:</span>{' '} | |
| <span className="font-medium"> | |
| {format(new Date(selectedRequest.submittedDate), 'MMM dd, yyyy')} | |
| </span> | |
| </div> | |
| </div> | |
| <div className="text-sm"> | |
| <span className="text-muted-foreground">Justification:</span>{' '} | |
| <span>{selectedRequest.justification}</span> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Allocation Progress */} | |
| <div className="space-y-3"> | |
| <h3 className="text-sm font-semibold">Allocation Progress</h3> | |
| {assetAllocations.map((allocation, idx) => { | |
| const progress = getAllocationProgress(allocation.assetType); | |
| const isComplete = progress.current === progress.total; | |
| return ( | |
| <Card key={idx} className={isComplete ? 'border-green-500' : 'border-2'}> | |
| <CardContent className="p-4"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <div className="flex items-center gap-2"> | |
| <Package className="h-4 w-4" /> | |
| <span className="font-medium">{allocation.assetType}</span> | |
| </div> | |
| <Badge | |
| className={ | |
| isComplete | |
| ? 'bg-green-100 text-green-800 border-green-200' | |
| : 'bg-yellow-100 text-yellow-800 border-yellow-200' | |
| } | |
| > | |
| {progress.current}/{progress.total} Assigned | |
| </Badge> | |
| </div> | |
| {/* Allocated Assets */} | |
| {allocation.allocatedAssets.length > 0 && ( | |
| <div className="space-y-2"> | |
| {allocation.allocatedAssets.map((asset) => ( | |
| <div | |
| key={asset.assetID} | |
| className="flex items-center justify-between bg-muted/50 rounded p-2 text-sm" | |
| > | |
| <div> | |
| <span className="font-medium">{asset.assetCode}</span> | |
| <span className="text-muted-foreground ml-2"> | |
| {asset.brandModel} | |
| </span> | |
| </div> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => | |
| handleRemoveAsset(asset.assetID, allocation.assetType) | |
| } | |
| className="h-7 w-7 p-0" | |
| > | |
| <Minus className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| ); | |
| })} | |
| </div> | |
| {/* Asset Selection */} | |
| <div className="space-y-3"> | |
| <h3 className="text-sm font-semibold">Available Assets</h3> | |
| {/* Filters */} | |
| <div className="flex 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 code, type, brand, or serial number..." | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| className="pl-10" | |
| /> | |
| </div> | |
| <Select value={filterAssetType} onValueChange={setFilterAssetType}> | |
| <SelectTrigger className="w-48"> | |
| <SelectValue placeholder="Filter by type" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="all">All Types</SelectItem> | |
| {assetTypes.map((type) => ( | |
| <SelectItem key={type} value={type}> | |
| {type} | |
| </SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| {/* Assets List */} | |
| <div className="border rounded-md max-h-64 overflow-y-auto"> | |
| {filteredAvailableAssets.length === 0 ? ( | |
| <div className="flex flex-col items-center justify-center py-8 text-center"> | |
| <AlertCircle className="h-8 w-8 text-muted-foreground mb-2" /> | |
| <p className="text-sm text-muted-foreground"> | |
| No available assets match your search | |
| </p> | |
| </div> | |
| ) : ( | |
| <Table> | |
| <TableHeader> | |
| <TableRow> | |
| <TableHead>Asset Code</TableHead> | |
| <TableHead>Type</TableHead> | |
| <TableHead>Brand/Model</TableHead> | |
| <TableHead>Condition</TableHead> | |
| <TableHead className="text-right">Action</TableHead> | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {filteredAvailableAssets.map((asset) => { | |
| const allocated = isAssetAllocated(asset.assetID); | |
| const canAdd = canAddMoreAssets(asset.assetType); | |
| return ( | |
| <TableRow key={asset.assetID}> | |
| <TableCell className="font-medium">{asset.assetCode}</TableCell> | |
| <TableCell>{asset.assetType}</TableCell> | |
| <TableCell>{asset.brandModel}</TableCell> | |
| <TableCell> | |
| <Badge variant="outline">{asset.assetCondition}</Badge> | |
| </TableCell> | |
| <TableCell className="text-right"> | |
| {allocated ? ( | |
| <Badge className="bg-green-100 text-green-800 border-green-200"> | |
| <CheckCircle className="h-3 w-3 mr-1" /> | |
| Allocated | |
| </Badge> | |
| ) : ( | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => handleAddAsset(asset, asset.assetType)} | |
| disabled={!canAdd} | |
| > | |
| <Plus className="h-4 w-4 mr-1" /> | |
| Add | |
| </Button> | |
| )} | |
| </TableCell> | |
| </TableRow> | |
| ); | |
| })} | |
| </TableBody> | |
| </Table> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <DialogFooter> | |
| <Button | |
| variant="outline" | |
| onClick={() => setShowAssignmentDialog(false)} | |
| disabled={isSubmitting} | |
| > | |
| Cancel | |
| </Button> | |
| <Button | |
| onClick={handleAssignSubmit} | |
| disabled={isSubmitting || !isAllocationComplete()} | |
| className="bg-green-600 hover:bg-green-700" | |
| > | |
| {isSubmitting ? ( | |
| <> | |
| <Loader2 className="h-4 w-4 mr-2 animate-spin" /> | |
| Assigning... | |
| </> | |
| ) : ( | |
| <> | |
| <CheckCircle className="h-4 w-4 mr-2" /> | |
| Confirm Assignment | |
| </> | |
| )} | |
| </Button> | |
| </DialogFooter> | |
| </DialogContent> | |
| </Dialog> | |
| </div> | |
| ); | |
| }; | |
| export default AssetRequestAssignment; | |