UNPKG

ajinkya-mhetre-mern

Version:

A MERN starter with frontend and backend folders

576 lines (535 loc) 27.2 kB
import React, { useEffect, useState } from 'react'; import { useAuth } from '../../contexts/AuthContext'; import { Search, Filter, Eye, Edit, Trash2, CheckCircle, XCircle, MapPin, Phone, Mail, Calendar } from 'lucide-react'; const baseUrl = import.meta.env.VITE_API_URL; function Users() { const { token } = useAuth(); const [users, setUsers] = useState([]); const [filteredUsers, setFilteredUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [searchTerm, setSearchTerm] = useState(''); const [roleFilter, setRoleFilter] = useState('all'); const [verificationFilter, setVerificationFilter] = useState('all'); const [currentPage, setCurrentPage] = useState(1); const [selectedUser, setSelectedUser] = useState(null); const [showUserModal, setShowUserModal] = useState(false); const usersPerPage = 10; useEffect(() => { fetchUsers(); }, []); useEffect(() => { filterUsers(); }, [users, searchTerm, roleFilter, verificationFilter]); const fetchUsers = async () => { try { const res = await fetch(`${baseUrl}/admin/users`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, }); const data = await res.json(); if (data.success) { setUsers(data.users); } else { setError('Failed to fetch users.'); } } catch (err) { setError('Error fetching users.'); } finally { setLoading(false); } }; const filterUsers = () => { let filtered = users.filter(user => { const matchesSearch = user.name.toLowerCase().includes(searchTerm.toLowerCase()) || user.email.toLowerCase().includes(searchTerm.toLowerCase()) || user.phone.includes(searchTerm); const matchesRole = roleFilter === 'all' || user.role === roleFilter; const matchesVerification = verificationFilter === 'all' || (verificationFilter === 'verified' && user.isVerified) || (verificationFilter === 'unverified' && !user.isVerified); return matchesSearch && matchesRole && matchesVerification; }); setFilteredUsers(filtered); setCurrentPage(1); }; const getRoleBadgeColor = (role) => { switch (role) { case 'admin': return 'bg-purple-100 text-purple-800 border-purple-200'; case 'farmer': return 'bg-green-100 text-green-800 border-green-200'; case 'customer': return 'bg-blue-100 text-blue-800 border-blue-200'; default: return 'bg-gray-100 text-gray-800 border-gray-200'; } }; const formatDate = (dateString) => { return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); }; const getCertificationBadges = (certifications) => { if (!certifications || certifications.length === 0) { return <span className="text-gray-400 text-sm">No certifications</span>; } return ( <div className="flex flex-wrap gap-1"> {certifications.slice(0, 2).map((cert, index) => ( <span key={index} className="px-2 py-1 bg-yellow-100 text-yellow-800 text-xs rounded-full border border-yellow-200"> {cert} </span> ))} {certifications.length > 2 && ( <span className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full"> +{certifications.length - 2} more </span> )} </div> ); }; const openUserModal = (user) => { setSelectedUser(user); setShowUserModal(true); }; // Pagination logic const indexOfLastUser = currentPage * usersPerPage; const indexOfFirstUser = indexOfLastUser - usersPerPage; const currentUsers = filteredUsers.slice(indexOfFirstUser, indexOfLastUser); const totalPages = Math.ceil(filteredUsers.length / usersPerPage); if (loading) { return ( <div className="w-full min-h-screen bg-gray-50 flex items-center justify-center"> <div className="text-center"> <div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto"></div> <p className="mt-4 text-gray-600">Loading users...</p> </div> </div> ); } if (error) { return ( <div className="w-full min-h-screen bg-gray-50 flex items-center justify-center"> <div className="text-center text-red-600"> <XCircle className="h-16 w-16 mx-auto mb-4" /> <p className="text-xl font-semibold">{error}</p> </div> </div> ); } return ( <div className="w-full min-h-screen bg-gray-50 p-6"> <div className="max-w-7xl mx-auto"> {/* Header */} <div className="mb-8"> <h1 className="text-3xl font-bold text-gray-900 mb-2">User Management</h1> <p className="text-gray-600">Manage and monitor all platform users</p> </div> {/* Stats Cards */} <div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"> <div className="bg-white rounded-lg shadow-sm p-6 border border-gray-200"> <div className="flex items-center"> <div className="p-2 bg-blue-100 rounded-lg"> <Eye className="h-6 w-6 text-blue-600" /> </div> <div className="ml-4"> <p className="text-sm font-medium text-gray-600">Total Users</p> <p className="text-2xl font-bold text-gray-900">{users.length}</p> </div> </div> </div> <div className="bg-white rounded-lg shadow-sm p-6 border border-gray-200"> <div className="flex items-center"> <div className="p-2 bg-green-100 rounded-lg"> <CheckCircle className="h-6 w-6 text-green-600" /> </div> <div className="ml-4"> <p className="text-sm font-medium text-gray-600">Farmers</p> <p className="text-2xl font-bold text-gray-900"> {users.filter(u => u.role === 'farmer').length} </p> </div> </div> </div> <div className="bg-white rounded-lg shadow-sm p-6 border border-gray-200"> <div className="flex items-center"> <div className="p-2 bg-purple-100 rounded-lg"> <XCircle className="h-6 w-6 text-purple-600" /> </div> <div className="ml-4"> <p className="text-sm font-medium text-gray-600">Customers</p> <p className="text-2xl font-bold text-gray-900"> {users.filter(u => u.role === 'customer').length} </p> </div> </div> </div> <div className="bg-white rounded-lg shadow-sm p-6 border border-gray-200"> <div className="flex items-center"> <div className="p-2 bg-yellow-100 rounded-lg"> <XCircle className="h-6 w-6 text-yellow-600" /> </div> <div className="ml-4"> <p className="text-sm font-medium text-gray-600">Unverified</p> <p className="text-2xl font-bold text-gray-900"> {users.filter(u => !u.isVerified).length} </p> </div> </div> </div> </div> {/* Filters */} <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6"> <div className="flex flex-col md:flex-row gap-4"> <div className="flex-1"> <div className="relative"> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-5 w-5" /> <input type="text" placeholder="Search by name, email, or phone..." className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} /> </div> </div> <div className="flex gap-4"> <select className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" value={roleFilter} onChange={(e) => setRoleFilter(e.target.value)} > <option value="all">All Roles</option> <option value="admin">Admin</option> <option value="farmer">Farmer</option> <option value="customer">Customer</option> </select> <select className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" value={verificationFilter} onChange={(e) => setVerificationFilter(e.target.value)} > <option value="all">All Status</option> <option value="verified">Verified</option> <option value="unverified">Unverified</option> </select> </div> </div> </div> {/* Table */} <div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"> <div className="overflow-x-auto"> <table className="min-w-full divide-y divide-gray-200"> <thead className="bg-gray-50"> <tr> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> User Info </th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> Contact </th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> Role & Status </th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> Farm Details </th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> Joined Date </th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> Actions </th> </tr> </thead> <tbody className="bg-white divide-y divide-gray-200"> {currentUsers.map((user) => ( <tr key={user._id} className="hover:bg-gray-50 transition-colors"> <td className="px-6 py-4 whitespace-nowrap"> <div className="flex items-center"> <div className="flex-shrink-0 h-10 w-10"> <div className="h-10 w-10 rounded-full bg-gradient-to-r from-blue-500 to-purple-600 flex items-center justify-center"> <span className="text-white font-medium text-sm"> {user.name.charAt(0).toUpperCase()} </span> </div> </div> <div className="ml-4"> <div className="text-sm font-medium text-gray-900">{user.name}</div> <div className="text-sm text-gray-500">ID: {user._id.slice(-8)}</div> </div> </div> </td> <td className="px-6 py-4 whitespace-nowrap"> <div className="space-y-1"> <div className="flex items-center text-sm text-gray-900"> <Mail className="h-4 w-4 text-gray-400 mr-2" /> {user.email} </div> <div className="flex items-center text-sm text-gray-500"> <Phone className="h-4 w-4 text-gray-400 mr-2" /> {user.phone} </div> <div className="flex items-center text-sm text-gray-500"> <MapPin className="h-4 w-4 text-gray-400 mr-2" /> {user.address.city}, {user.address.state} </div> </div> </td> <td className="px-6 py-4 whitespace-nowrap"> <div className="space-y-2"> <span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full border ${getRoleBadgeColor(user.role)}`}> {user.role.charAt(0).toUpperCase() + user.role.slice(1)} </span> <div className="flex items-center"> {user.isVerified ? ( <CheckCircle className="h-4 w-4 text-green-500 mr-1" /> ) : ( <XCircle className="h-4 w-4 text-red-500 mr-1" /> )} <span className={`text-xs ${user.isVerified ? 'text-green-600' : 'text-red-600'}`}> {user.isVerified ? 'Verified' : 'Unverified'} </span> </div> </div> </td> <td className="px-6 py-4"> {user.role === 'farmer' && user.farmDetails ? ( <div className="space-y-1"> <div className="text-sm font-medium text-gray-900"> {user.farmDetails.farmName || 'No farm name'} </div> <div className="text-sm text-gray-500"> Size: {user.farmDetails.farmSize || 'N/A'} </div> <div className="text-sm text-gray-500"> {user.farmDetails.farmLocation || 'No location'} </div> {getCertificationBadges(user.farmDetails.certifications)} </div> ) : ( <span className="text-sm text-gray-400">No farm details</span> )} </td> <td className="px-6 py-4 whitespace-nowrap"> <div className="flex items-center text-sm text-gray-500"> <Calendar className="h-4 w-4 text-gray-400 mr-2" /> {formatDate(user.createdAt)} </div> </td> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2"> <button onClick={() => openUserModal(user)} className="text-blue-600 hover:text-blue-900 transition-colors" > <Eye className="h-4 w-4" /> </button> <button className="text-green-600 hover:text-green-900 transition-colors"> <Edit className="h-4 w-4" /> </button> <button className="text-red-600 hover:text-red-900 transition-colors"> <Trash2 className="h-4 w-4" /> </button> </td> </tr> ))} </tbody> </table> </div> {/* Pagination */} <div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6"> <div className="flex-1 flex justify-between sm:hidden"> <button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50" > Previous </button> <button onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50" > Next </button> </div> <div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"> <div> <p className="text-sm text-gray-700"> Showing <span className="font-medium">{indexOfFirstUser + 1}</span> to{' '} <span className="font-medium"> {Math.min(indexOfLastUser, filteredUsers.length)} </span>{' '} of <span className="font-medium">{filteredUsers.length}</span> results </p> </div> <div> <nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"> <button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50" > Previous </button> {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { const pageNum = i + 1; return ( <button key={pageNum} onClick={() => setCurrentPage(pageNum)} className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${ currentPage === pageNum ? 'z-10 bg-blue-50 border-blue-500 text-blue-600' : 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50' }`} > {pageNum} </button> ); })} <button onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50" > Next </button> </nav> </div> </div> </div> </div> {/* User Detail Modal */} {showUserModal && selectedUser && ( <div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 flex items-center justify-center p-4"> <div className="relative bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"> <div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex justify-between items-center"> <h3 className="text-lg font-medium text-gray-900">User Details</h3> <button onClick={() => setShowUserModal(false)} className="text-gray-400 hover:text-gray-600 transition-colors" > <XCircle className="h-6 w-6" /> </button> </div> <div className="p-6 space-y-6"> {/* Personal Information */} <div> <h4 className="text-md font-semibold text-gray-900 mb-3">Personal Information</h4> <div className="grid grid-cols-2 gap-4"> <div> <label className="block text-sm font-medium text-gray-600">Name</label> <p className="text-sm text-gray-900">{selectedUser.name}</p> </div> <div> <label className="block text-sm font-medium text-gray-600">Email</label> <p className="text-sm text-gray-900">{selectedUser.email}</p> </div> <div> <label className="block text-sm font-medium text-gray-600">Phone</label> <p className="text-sm text-gray-900">{selectedUser.phone}</p> </div> <div> <label className="block text-sm font-medium text-gray-600">Role</label> <span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full border ${getRoleBadgeColor(selectedUser.role)}`}> {selectedUser.role.charAt(0).toUpperCase() + selectedUser.role.slice(1)} </span> </div> </div> </div> {/* Address */} <div> <h4 className="text-md font-semibold text-gray-900 mb-3">Address</h4> <div className="bg-gray-50 p-4 rounded-lg"> <p className="text-sm text-gray-900"> {selectedUser.address.street}<br /> {selectedUser.address.city}, {selectedUser.address.state} {selectedUser.address.zipCode} </p> </div> </div> {/* Farm Details (if farmer) */} {selectedUser.role === 'farmer' && selectedUser.farmDetails && ( <div> <h4 className="text-md font-semibold text-gray-900 mb-3">Farm Details</h4> <div className="bg-green-50 p-4 rounded-lg space-y-3"> <div className="grid grid-cols-2 gap-4"> <div> <label className="block text-sm font-medium text-gray-600">Farm Name</label> <p className="text-sm text-gray-900">{selectedUser.farmDetails.farmName || 'Not specified'}</p> </div> <div> <label className="block text-sm font-medium text-gray-600">Farm Size</label> <p className="text-sm text-gray-900">{selectedUser.farmDetails.farmSize || 'Not specified'}</p> </div> </div> <div> <label className="block text-sm font-medium text-gray-600">Location</label> <p className="text-sm text-gray-900">{selectedUser.farmDetails.farmLocation || 'Not specified'}</p> </div> <div> <label className="block text-sm font-medium text-gray-600">Certifications</label> <div className="mt-1"> {selectedUser.farmDetails.certifications && selectedUser.farmDetails.certifications.length > 0 ? ( <div className="flex flex-wrap gap-2"> {selectedUser.farmDetails.certifications.map((cert, index) => ( <span key={index} className="px-2 py-1 bg-yellow-100 text-yellow-800 text-xs rounded-full border border-yellow-200"> {cert} </span> ))} </div> ) : ( <p className="text-sm text-gray-500">No certifications</p> )} </div> </div> </div> </div> )} {/* Account Status */} <div> <h4 className="text-md font-semibold text-gray-900 mb-3">Account Status</h4> <div className="grid grid-cols-2 gap-4"> <div> <label className="block text-sm font-medium text-gray-600">Verification Status</label> <div className="flex items-center mt-1"> {selectedUser.isVerified ? ( <CheckCircle className="h-4 w-4 text-green-500 mr-1" /> ) : ( <XCircle className="h-4 w-4 text-red-500 mr-1" /> )} <span className={`text-sm ${selectedUser.isVerified ? 'text-green-600' : 'text-red-600'}`}> {selectedUser.isVerified ? 'Verified' : 'Unverified'} </span> </div> </div> <div> <label className="block text-sm font-medium text-gray-600">Member Since</label> <p className="text-sm text-gray-900">{formatDate(selectedUser.createdAt)}</p> </div> </div> </div> </div> <div className="sticky bottom-0 bg-gray-50 px-6 py-4 flex justify-end space-x-3 border-t border-gray-200"> <button onClick={() => setShowUserModal(false)} className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors" > Close </button> <button className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 transition-colors"> Edit User </button> </div> </div> </div> )} </div> </div> ); } export default Users;