Spaces:
Sleeping
Sleeping
| import React, { useState } from 'react'; | |
| import { useAuth } from '@/lib/auth-context'; | |
| import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; | |
| import { Link } from 'react-router-dom'; | |
| import Layout from '@/components/layout/layout'; | |
| import { Card, CardContent } from '@/components/ui/card'; | |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; | |
| import { Package, Loader2, AlertCircle, Shield, Home } from 'lucide-react'; | |
| import { Button } from '@/components/ui/button'; | |
| import { | |
| Breadcrumb, | |
| BreadcrumbList, | |
| BreadcrumbItem, | |
| BreadcrumbLink, | |
| BreadcrumbPage, | |
| BreadcrumbSeparator, | |
| } from '@/components/ui/breadcrumb'; | |
| import AssetRequestReview from '@/components/asset-request/AssetRequestReview'; | |
| import AssetRequestAssignment from '@/components/asset-request/AssetRequestAssignment'; | |
| import AssetRequestDashboard from '@/components/asset-request/AssetRequestDashboard'; | |
| import ErrorBoundary from '@/components/asset-request/ErrorBoundary'; | |
| import { ErrorDisplay } from '@/components/asset-request/ErrorDisplay'; | |
| import { | |
| AssetRequestReviewSkeleton, | |
| AssetRequestDashboardSkeleton, | |
| AssetRequestAssignmentSkeleton, | |
| } from '@/components/asset-request/LoadingSkeletons'; | |
| import { assetRequestApi } from '@/services/assetRequestApi'; | |
| import { assetApi } from '@/services/assetApi'; | |
| import { assetAssignmentApi } from '@/services/assetAssignmentApi'; | |
| import { | |
| AssetRequestApprovalRequest, | |
| AssetRequestRejectionRequest, | |
| AssetRequestAssignmentRequest, | |
| } from '@/types/asset-request'; | |
| import { | |
| notifyAdminApprovalSuccess, | |
| notifyAdminRejectionSuccess, | |
| notifyAdminAssignmentSuccess, | |
| notifyError, | |
| } from '@/utils/assetRequestNotifications'; | |
| const AssetRequestManagementPage: React.FC = () => { | |
| const { userData } = useAuth(); | |
| const queryClient = useQueryClient(); | |
| const [activeTab, setActiveTab] = useState('pending'); | |
| // Check if user is admin | |
| const isAdmin = userData?.roleId === 1 || userData?.roleName?.toLowerCase() === 'admin'; | |
| // Get current user ID | |
| const currentUserId = userData?.employeeId || 0; | |
| // Fetch pending requests with availability | |
| const { | |
| data: pendingRequests = [], | |
| isLoading: isPendingLoading, | |
| error: pendingError, | |
| refetch: refetchPending, | |
| } = useQuery({ | |
| queryKey: ['assetRequests', 'pending'], | |
| queryFn: () => assetRequestApi.getPending(), | |
| enabled: isAdmin, | |
| staleTime: 30000, // 30 seconds | |
| refetchInterval: 60000, // Refresh every minute | |
| }); | |
| // Fetch all requests for dashboard | |
| const { | |
| data: allRequests = [], | |
| isLoading: isAllLoading, | |
| error: allError, | |
| refetch: refetchAll, | |
| } = useQuery({ | |
| queryKey: ['assetRequests', 'all'], | |
| queryFn: () => assetRequestApi.getAll(), | |
| enabled: isAdmin, | |
| staleTime: 30000, | |
| refetchInterval: 60000, | |
| }); | |
| // Fetch approved requests (for assignment queue) | |
| const approvedRequests = allRequests.filter((req) => req.status === 'approved'); | |
| // Fetch available assets | |
| const { | |
| data: allAssets = [], | |
| isLoading: isAssetsLoading, | |
| } = useQuery({ | |
| queryKey: ['assets', 'all'], | |
| queryFn: () => assetApi.getAll(), | |
| enabled: isAdmin, | |
| staleTime: 30000, | |
| }); | |
| // Fetch all assignments to determine available assets | |
| const { | |
| data: allAssignments = [], | |
| isLoading: isAssignmentsLoading, | |
| } = useQuery({ | |
| queryKey: ['assetAssignments', 'all'], | |
| queryFn: () => assetAssignmentApi.getAll(), | |
| enabled: isAdmin, | |
| staleTime: 30000, | |
| }); | |
| // Filter available assets (not currently assigned) | |
| const availableAssets = allAssets.filter((asset) => { | |
| const activeAssignment = allAssignments.find( | |
| (assignment) => assignment.assetID === asset.assetID && !assignment.returnedDate | |
| ); | |
| return !activeAssignment; | |
| }); | |
| // Approve request mutation with optimistic updates | |
| const approveMutation = useMutation({ | |
| mutationFn: ({ id, data }: { id: number; data: AssetRequestApprovalRequest }) => | |
| assetRequestApi.approve(id, data), | |
| onMutate: async ({ id, data }) => { | |
| // Cancel outgoing refetches | |
| await queryClient.cancelQueries({ queryKey: ['assetRequests'] }); | |
| // Snapshot previous values | |
| const previousPending = queryClient.getQueryData(['assetRequests', 'pending']); | |
| const previousAll = queryClient.getQueryData(['assetRequests', 'all']); | |
| // Optimistically update | |
| const updateRequest = (old: any) => { | |
| if (!old) return old; | |
| return old.map((req: any) => | |
| req.requestID === id | |
| ? { | |
| ...req, | |
| status: 'approved', | |
| reviewedDate: new Date().toISOString(), | |
| reviewedBy: data.reviewedBy, | |
| reviewerName: userData ? `${userData.firstName} ${userData.lastName}` : '', | |
| adminComments: data.adminComments, | |
| } | |
| : req | |
| ); | |
| }; | |
| queryClient.setQueryData(['assetRequests', 'pending'], (old: any) => | |
| old ? old.filter((req: any) => req.requestID !== id) : old | |
| ); | |
| queryClient.setQueryData(['assetRequests', 'all'], updateRequest); | |
| return { previousPending, previousAll }; | |
| }, | |
| onSuccess: (updatedRequest) => { | |
| queryClient.invalidateQueries({ queryKey: ['assetRequests'] }); | |
| notifyAdminApprovalSuccess(updatedRequest); | |
| }, | |
| onError: (error: Error, _variables, context) => { | |
| // Rollback on error | |
| if (context?.previousPending) { | |
| queryClient.setQueryData(['assetRequests', 'pending'], context.previousPending); | |
| } | |
| if (context?.previousAll) { | |
| queryClient.setQueryData(['assetRequests', 'all'], context.previousAll); | |
| } | |
| notifyError('Approve Request', error.message || 'Failed to approve the request. Please try again.'); | |
| }, | |
| }); | |
| // Reject request mutation with optimistic updates | |
| const rejectMutation = useMutation({ | |
| mutationFn: ({ id, data }: { id: number; data: AssetRequestRejectionRequest }) => | |
| assetRequestApi.reject(id, data), | |
| onMutate: async ({ id, data }) => { | |
| await queryClient.cancelQueries({ queryKey: ['assetRequests'] }); | |
| const previousPending = queryClient.getQueryData(['assetRequests', 'pending']); | |
| const previousAll = queryClient.getQueryData(['assetRequests', 'all']); | |
| const updateRequest = (old: any) => { | |
| if (!old) return old; | |
| return old.map((req: any) => | |
| req.requestID === id | |
| ? { | |
| ...req, | |
| status: 'rejected', | |
| reviewedDate: new Date().toISOString(), | |
| reviewedBy: data.reviewedBy, | |
| reviewerName: userData ? `${userData.firstName} ${userData.lastName}` : '', | |
| adminComments: data.adminComments, | |
| } | |
| : req | |
| ); | |
| }; | |
| queryClient.setQueryData(['assetRequests', 'pending'], (old: any) => | |
| old ? old.filter((req: any) => req.requestID !== id) : old | |
| ); | |
| queryClient.setQueryData(['assetRequests', 'all'], updateRequest); | |
| return { previousPending, previousAll }; | |
| }, | |
| onSuccess: (updatedRequest) => { | |
| queryClient.invalidateQueries({ queryKey: ['assetRequests'] }); | |
| notifyAdminRejectionSuccess(updatedRequest); | |
| }, | |
| onError: (error: Error, _variables, context) => { | |
| if (context?.previousPending) { | |
| queryClient.setQueryData(['assetRequests', 'pending'], context.previousPending); | |
| } | |
| if (context?.previousAll) { | |
| queryClient.setQueryData(['assetRequests', 'all'], context.previousAll); | |
| } | |
| notifyError('Reject Request', error.message || 'Failed to reject the request. Please try again.'); | |
| }, | |
| }); | |
| // Add comment mutation | |
| const addCommentMutation = useMutation({ | |
| mutationFn: ({ id, comments }: { id: number; comments: string }) => | |
| assetRequestApi.addComment(id, { | |
| adminComments: comments, | |
| commentedBy: currentUserId, | |
| }), | |
| onSuccess: () => { | |
| queryClient.invalidateQueries({ queryKey: ['assetRequests'] }); | |
| notifyAdminApprovalSuccess({ requestID: 0 } as any); // Use generic success toast | |
| }, | |
| onError: (error: Error) => { | |
| notifyError('Add Comment', error.message || 'Failed to add comment. Please try again.'); | |
| }, | |
| }); | |
| // Assign assets mutation with optimistic updates | |
| const assignMutation = useMutation({ | |
| mutationFn: ({ id, data }: { id: number; data: AssetRequestAssignmentRequest }) => | |
| assetRequestApi.assign(id, data), | |
| onMutate: async ({ id, data }) => { | |
| await queryClient.cancelQueries({ queryKey: ['assetRequests'] }); | |
| await queryClient.cancelQueries({ queryKey: ['assets'] }); | |
| await queryClient.cancelQueries({ queryKey: ['assetAssignments'] }); | |
| const previousAll = queryClient.getQueryData(['assetRequests', 'all']); | |
| const updateRequest = (old: any) => { | |
| if (!old) return old; | |
| return old.map((req: any) => | |
| req.requestID === id | |
| ? { | |
| ...req, | |
| status: 'assigned', | |
| assignedDate: new Date().toISOString(), | |
| assignedBy: data.assignedBy, | |
| assignerName: userData ? `${userData.firstName} ${userData.lastName}` : '', | |
| } | |
| : req | |
| ); | |
| }; | |
| queryClient.setQueryData(['assetRequests', 'all'], updateRequest); | |
| return { previousAll }; | |
| }, | |
| onSuccess: (updatedRequest) => { | |
| queryClient.invalidateQueries({ queryKey: ['assetRequests'] }); | |
| queryClient.invalidateQueries({ queryKey: ['assets'] }); | |
| queryClient.invalidateQueries({ queryKey: ['assetAssignments'] }); | |
| notifyAdminAssignmentSuccess(updatedRequest); | |
| }, | |
| onError: (error: Error, _variables, context) => { | |
| if (context?.previousAll) { | |
| queryClient.setQueryData(['assetRequests', 'all'], context.previousAll); | |
| } | |
| notifyError('Assign Assets', error.message || 'Failed to assign assets. Please try again.'); | |
| }, | |
| }); | |
| const handleApprove = async (id: number, data: AssetRequestApprovalRequest) => { | |
| await approveMutation.mutateAsync({ id, data }); | |
| }; | |
| const handleReject = async (id: number, data: AssetRequestRejectionRequest) => { | |
| await rejectMutation.mutateAsync({ id, data }); | |
| }; | |
| const handleAddComment = async (id: number, comments: string) => { | |
| await addCommentMutation.mutateAsync({ id, comments }); | |
| }; | |
| const handleAssign = async (id: number, data: AssetRequestAssignmentRequest) => { | |
| await assignMutation.mutateAsync({ id, data }); | |
| }; | |
| const handleRetry = () => { | |
| refetchPending(); | |
| refetchAll(); | |
| }; | |
| // Loading state | |
| if (!userData) { | |
| return ( | |
| <Layout> | |
| <div className="flex items-center justify-center min-h-[400px]"> | |
| <div className="text-center"> | |
| <Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-4" /> | |
| <p className="text-muted-foreground">Loading user information...</p> | |
| </div> | |
| </div> | |
| </Layout> | |
| ); | |
| } | |
| // Access denied for non-admin users | |
| if (!isAdmin) { | |
| return ( | |
| <Layout> | |
| <div className="flex items-center justify-center min-h-[400px]"> | |
| <Card className="max-w-md"> | |
| <CardContent className="pt-6"> | |
| <div className="text-center"> | |
| <Shield className="h-12 w-12 text-destructive mx-auto mb-4" /> | |
| <h2 className="text-xl font-semibold mb-2">Access Denied</h2> | |
| <p className="text-muted-foreground mb-4"> | |
| You do not have permission to access this page. Only administrators can | |
| manage asset requests. | |
| </p> | |
| <Button onClick={() => window.history.back()}>Go Back</Button> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| </Layout> | |
| ); | |
| } | |
| return ( | |
| <Layout> | |
| {/* Breadcrumb Navigation */} | |
| <Breadcrumb className="mb-4"> | |
| <BreadcrumbList> | |
| <BreadcrumbItem> | |
| <BreadcrumbLink asChild> | |
| <Link to="/dashboard" className="flex items-center gap-1"> | |
| <Home className="h-4 w-4" /> | |
| <span>Home</span> | |
| </Link> | |
| </BreadcrumbLink> | |
| </BreadcrumbItem> | |
| <BreadcrumbSeparator /> | |
| <BreadcrumbItem> | |
| <BreadcrumbLink asChild> | |
| <Link to="/assets">Asset Management</Link> | |
| </BreadcrumbLink> | |
| </BreadcrumbItem> | |
| <BreadcrumbSeparator /> | |
| <BreadcrumbItem> | |
| <BreadcrumbPage>Asset Request Management</BreadcrumbPage> | |
| </BreadcrumbItem> | |
| </BreadcrumbList> | |
| </Breadcrumb> | |
| {/* Page Header */} | |
| <div className="mb-6"> | |
| <h1 className="text-2xl sm:text-3xl font-bold text-gray-900 flex items-center gap-2"> | |
| <Package className="h-6 w-6 sm:h-8 sm:w-8 shrink-0" aria-hidden="true" /> | |
| <span>Asset Request Management</span> | |
| </h1> | |
| <p className="text-muted-foreground mt-1 text-sm sm:text-base"> | |
| Review, approve, and assign assets to employee requests | |
| </p> | |
| </div> | |
| {/* Error State */} | |
| {(pendingError || allError) && ( | |
| <div className="mb-6"> | |
| <ErrorDisplay | |
| error={pendingError || allError} | |
| title="Failed to Load Requests" | |
| onRetry={handleRetry} | |
| variant="card" | |
| /> | |
| </div> | |
| )} | |
| {/* Tabbed Interface */} | |
| <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4"> | |
| <TabsList className="grid w-full grid-cols-3"> | |
| <TabsTrigger value="pending" className="relative"> | |
| Pending Requests | |
| {pendingRequests.length > 0 && ( | |
| <span className="ml-2 bg-yellow-500 text-white text-xs rounded-full px-2 py-0.5"> | |
| {pendingRequests.length} | |
| </span> | |
| )} | |
| </TabsTrigger> | |
| <TabsTrigger value="all">All Requests</TabsTrigger> | |
| <TabsTrigger value="assignment" className="relative"> | |
| Assignment Queue | |
| {approvedRequests.length > 0 && ( | |
| <span className="ml-2 bg-green-500 text-white text-xs rounded-full px-2 py-0.5"> | |
| {approvedRequests.length} | |
| </span> | |
| )} | |
| </TabsTrigger> | |
| </TabsList> | |
| {/* Pending Requests Tab */} | |
| <TabsContent value="pending"> | |
| <ErrorBoundary onReset={refetchPending}> | |
| {isPendingLoading ? ( | |
| <AssetRequestReviewSkeleton /> | |
| ) : ( | |
| <AssetRequestReview | |
| requests={pendingRequests} | |
| isLoading={isPendingLoading} | |
| onApprove={handleApprove} | |
| onReject={handleReject} | |
| onAddComment={handleAddComment} | |
| currentUserId={currentUserId} | |
| /> | |
| )} | |
| </ErrorBoundary> | |
| </TabsContent> | |
| {/* All Requests Tab */} | |
| <TabsContent value="all"> | |
| <ErrorBoundary onReset={refetchAll}> | |
| {isAllLoading ? ( | |
| <AssetRequestDashboardSkeleton /> | |
| ) : ( | |
| <AssetRequestDashboard requests={allRequests} isLoading={isAllLoading} /> | |
| )} | |
| </ErrorBoundary> | |
| </TabsContent> | |
| {/* Assignment Queue Tab */} | |
| <TabsContent value="assignment"> | |
| <ErrorBoundary onReset={handleRetry}> | |
| {isAssetsLoading || isAssignmentsLoading ? ( | |
| <AssetRequestAssignmentSkeleton /> | |
| ) : ( | |
| <AssetRequestAssignment | |
| approvedRequests={approvedRequests} | |
| availableAssets={availableAssets} | |
| allAssets={allAssets} | |
| activeAssignments={allAssignments.filter(a => !a.returnedDate)} | |
| isLoading={isAssetsLoading || isAssignmentsLoading} | |
| onAssign={handleAssign} | |
| currentUserId={currentUserId} | |
| /> | |
| )} | |
| </ErrorBoundary> | |
| </TabsContent> | |
| </Tabs> | |
| </Layout> | |
| ); | |
| }; | |
| export default AssetRequestManagementPage; | |