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 { | |
| Clock, | |
| CheckCircle, | |
| XCircle, | |
| Package, | |
| Search, | |
| Download, | |
| User, | |
| Calendar, | |
| TrendingUp, | |
| FileText, | |
| Loader2, | |
| } from 'lucide-react'; | |
| import { | |
| AssetRequest, | |
| RequestStatus, | |
| REQUEST_STATUS_LABELS, | |
| } from '@/types/asset-request'; | |
| interface AssetRequestDashboardProps { | |
| requests: AssetRequest[]; | |
| isLoading?: boolean; | |
| } | |
| interface DashboardStats { | |
| total: number; | |
| pending: number; | |
| approved: number; | |
| rejected: number; | |
| assigned: number; | |
| } | |
| const AssetRequestDashboard: React.FC<AssetRequestDashboardProps> = ({ | |
| requests, | |
| isLoading = false, | |
| }) => { | |
| const [searchTerm, setSearchTerm] = useState(''); | |
| const [statusFilter, setStatusFilter] = useState<RequestStatus | 'all'>('all'); | |
| const [dateRangeFilter, setDateRangeFilter] = useState<'all' | '7days' | '30days' | '90days'>( | |
| 'all' | |
| ); | |
| const [employeeFilter, setEmployeeFilter] = useState<string>('all'); | |
| // Calculate statistics | |
| const stats: DashboardStats = useMemo(() => { | |
| return { | |
| total: requests.length, | |
| pending: requests.filter((r) => r.status === 'pending').length, | |
| approved: requests.filter((r) => r.status === 'approved').length, | |
| rejected: requests.filter((r) => r.status === 'rejected').length, | |
| assigned: requests.filter((r) => r.status === 'assigned').length, | |
| }; | |
| }, [requests]); | |
| // Get unique employees | |
| const employees = useMemo(() => { | |
| const uniqueEmployees = new Map<number, string>(); | |
| requests.forEach((req) => { | |
| uniqueEmployees.set(req.employeeID, req.employeeName); | |
| }); | |
| return Array.from(uniqueEmployees.entries()).map(([id, name]) => ({ id, name })); | |
| }, [requests]); | |
| // Filter requests | |
| const filteredRequests = useMemo(() => { | |
| let filtered = [...requests]; | |
| // Status filter | |
| if (statusFilter !== 'all') { | |
| filtered = filtered.filter((req) => req.status === statusFilter); | |
| } | |
| // Date range filter | |
| if (dateRangeFilter !== 'all') { | |
| const now = new Date(); | |
| const daysAgo = { | |
| '7days': 7, | |
| '30days': 30, | |
| '90days': 90, | |
| }[dateRangeFilter]; | |
| const cutoffDate = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000); | |
| filtered = filtered.filter( | |
| (req) => new Date(req.submittedDate) >= cutoffDate | |
| ); | |
| } | |
| // Employee filter | |
| if (employeeFilter !== 'all') { | |
| filtered = filtered.filter((req) => req.employeeID.toString() === employeeFilter); | |
| } | |
| // 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) | |
| ) || | |
| (req.adminComments && req.adminComments.toLowerCase().includes(term)) | |
| ); | |
| } | |
| // Sort by date (newest first) | |
| return filtered.sort( | |
| (a, b) => | |
| new Date(b.submittedDate).getTime() - new Date(a.submittedDate).getTime() | |
| ); | |
| }, [requests, searchTerm, statusFilter, dateRangeFilter, employeeFilter]); | |
| const handleExport = () => { | |
| // Create CSV content | |
| const headers = [ | |
| 'Request ID', | |
| 'Employee', | |
| 'Status', | |
| 'Submitted Date', | |
| 'Assets', | |
| 'Justification', | |
| 'Reviewer', | |
| 'Admin Comments', | |
| ]; | |
| const rows = filteredRequests.map((req) => [ | |
| req.requestID, | |
| req.employeeName, | |
| req.status, | |
| format(new Date(req.submittedDate), 'yyyy-MM-dd'), | |
| req.requestedAssets.map((a) => `${a.assetType} (x${a.quantity})`).join('; '), | |
| req.justification.replace(/,/g, ';'), | |
| req.reviewerName || '', | |
| req.adminComments?.replace(/,/g, ';') || '', | |
| ]); | |
| const csvContent = [ | |
| headers.join(','), | |
| ...rows.map((row) => row.map((cell) => `"${cell}"`).join(',')), | |
| ].join('\n'); | |
| // Create and download file | |
| const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); | |
| const link = document.createElement('a'); | |
| const url = URL.createObjectURL(blob); | |
| link.setAttribute('href', url); | |
| link.setAttribute( | |
| 'download', | |
| `asset-requests-${format(new Date(), 'yyyy-MM-dd')}.csv` | |
| ); | |
| link.style.visibility = 'hidden'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| }; | |
| const getStatusIcon = (status: RequestStatus) => { | |
| switch (status) { | |
| case 'pending': | |
| return <Clock className="h-4 w-4" />; | |
| case 'approved': | |
| return <CheckCircle className="h-4 w-4" />; | |
| case 'rejected': | |
| return <XCircle className="h-4 w-4" />; | |
| case 'assigned': | |
| return <Package className="h-4 w-4" />; | |
| } | |
| }; | |
| const getStatusColor = (status: RequestStatus) => { | |
| switch (status) { | |
| case 'pending': | |
| return 'bg-yellow-100 text-yellow-800 border-yellow-200'; | |
| case 'approved': | |
| return 'bg-green-100 text-green-800 border-green-200'; | |
| case 'rejected': | |
| return 'bg-red-100 text-red-800 border-red-200'; | |
| case 'assigned': | |
| return 'bg-blue-100 text-blue-800 border-blue-200'; | |
| } | |
| }; | |
| 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"> | |
| {/* Statistics Cards */} | |
| <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4"> | |
| <Card> | |
| <CardContent className="pt-6"> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <p className="text-sm font-medium text-muted-foreground">Total Requests</p> | |
| <p className="text-2xl font-bold">{stats.total}</p> | |
| </div> | |
| <TrendingUp className="h-8 w-8 text-muted-foreground" /> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| <Card> | |
| <CardContent className="pt-6"> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <p className="text-sm font-medium text-muted-foreground">Pending</p> | |
| <p className="text-2xl font-bold text-yellow-600">{stats.pending}</p> | |
| </div> | |
| <Clock className="h-8 w-8 text-yellow-600" /> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| <Card> | |
| <CardContent className="pt-6"> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <p className="text-sm font-medium text-muted-foreground">Approved</p> | |
| <p className="text-2xl font-bold text-green-600">{stats.approved}</p> | |
| </div> | |
| <CheckCircle className="h-8 w-8 text-green-600" /> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| <Card> | |
| <CardContent className="pt-6"> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <p className="text-sm font-medium text-muted-foreground">Rejected</p> | |
| <p className="text-2xl font-bold text-red-600">{stats.rejected}</p> | |
| </div> | |
| <XCircle className="h-8 w-8 text-red-600" /> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| <Card> | |
| <CardContent className="pt-6"> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <p className="text-sm font-medium text-muted-foreground">Assigned</p> | |
| <p className="text-2xl font-bold text-blue-600">{stats.assigned}</p> | |
| </div> | |
| <Package className="h-8 w-8 text-blue-600" /> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| {/* Filters and Search */} | |
| <Card> | |
| <CardHeader> | |
| <div className="flex items-center justify-between"> | |
| <CardTitle className="text-lg">All Asset Requests</CardTitle> | |
| <Button variant="outline" size="sm" onClick={handleExport}> | |
| <Download className="h-4 w-4 mr-2" /> | |
| Export CSV | |
| </Button> | |
| </div> | |
| </CardHeader> | |
| <CardContent className="space-y-4"> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3"> | |
| <div className="relative"> | |
| <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> | |
| <Input | |
| placeholder="Search requests..." | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| className="pl-10" | |
| /> | |
| </div> | |
| <Select value={statusFilter} onValueChange={(value) => setStatusFilter(value as RequestStatus | 'all')}> | |
| <SelectTrigger> | |
| <SelectValue placeholder="Filter by status" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="all">All Statuses</SelectItem> | |
| <SelectItem value="pending">Pending</SelectItem> | |
| <SelectItem value="approved">Approved</SelectItem> | |
| <SelectItem value="rejected">Rejected</SelectItem> | |
| <SelectItem value="assigned">Assigned</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <Select value={dateRangeFilter} onValueChange={(value) => setDateRangeFilter(value as any)}> | |
| <SelectTrigger> | |
| <SelectValue placeholder="Filter by date" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="all">All Time</SelectItem> | |
| <SelectItem value="7days">Last 7 Days</SelectItem> | |
| <SelectItem value="30days">Last 30 Days</SelectItem> | |
| <SelectItem value="90days">Last 90 Days</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <Select value={employeeFilter} onValueChange={setEmployeeFilter}> | |
| <SelectTrigger> | |
| <SelectValue placeholder="Filter by employee" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="all">All Employees</SelectItem> | |
| {employees.map((emp) => ( | |
| <SelectItem key={emp.id} value={emp.id.toString()}> | |
| {emp.name} | |
| </SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Requests Table */} | |
| {filteredRequests.length === 0 ? ( | |
| <Card> | |
| <CardContent className="flex flex-col items-center justify-center py-12 text-center"> | |
| <FileText className="h-12 w-12 text-muted-foreground mb-4" /> | |
| <p className="text-muted-foreground"> | |
| {searchTerm || statusFilter !== 'all' || dateRangeFilter !== 'all' || employeeFilter !== 'all' | |
| ? 'No requests match your filters' | |
| : 'No asset requests found'} | |
| </p> | |
| </CardContent> | |
| </Card> | |
| ) : ( | |
| <Card> | |
| <CardContent className="p-0"> | |
| <div className="overflow-x-auto"> | |
| <Table> | |
| <TableHeader> | |
| <TableRow> | |
| <TableHead>Request ID</TableHead> | |
| <TableHead>Employee</TableHead> | |
| <TableHead>Status</TableHead> | |
| <TableHead>Assets</TableHead> | |
| <TableHead>Submitted</TableHead> | |
| <TableHead>Reviewer</TableHead> | |
| </TableRow> | |
| </TableHeader> | |
| <TableBody> | |
| {filteredRequests.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> | |
| <Badge | |
| variant="outline" | |
| className={`flex items-center gap-1 w-fit ${getStatusColor( | |
| request.status | |
| )}`} | |
| > | |
| {getStatusIcon(request.status)} | |
| {REQUEST_STATUS_LABELS[request.status]} | |
| </Badge> | |
| </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="flex items-center gap-2 text-sm"> | |
| <Calendar className="h-4 w-4 text-muted-foreground" /> | |
| <span>{format(new Date(request.submittedDate), 'MMM dd, yyyy')}</span> | |
| </div> | |
| </TableCell> | |
| <TableCell> | |
| {request.reviewerName ? ( | |
| <div className="text-sm">{request.reviewerName}</div> | |
| ) : ( | |
| <span className="text-sm text-muted-foreground">-</span> | |
| )} | |
| </TableCell> | |
| </TableRow> | |
| ))} | |
| </TableBody> | |
| </Table> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| )} | |
| {/* Results Count */} | |
| {filteredRequests.length > 0 && ( | |
| <div className="text-sm text-muted-foreground text-center"> | |
| Showing {filteredRequests.length} of {requests.length} requests | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default AssetRequestDashboard; | |