diff --git a/src/App.tsx b/src/App.tsx index 19593e4..7b8e9d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,8 @@ import Category from './pages/admin/Category'; import Sellers from './pages/admin/Sellers'; import Buyers from './pages/admin/Buyers'; import Messages from './pages/admin/Messages'; +import UserManagement from './pages/admin/UserManagement'; +import NotFoundPage from './pages/NotFoundPage'; import Settings from './pages/admin/Settings'; import CategoriesPage from './pages/CategoriesPage'; @@ -125,6 +127,10 @@ const App = () => { path: 'messages', element: , }, + { + path: 'users', + element: , + }, { path: 'settings', element: , @@ -135,6 +141,10 @@ const App = () => { path: 'search', element: , }, + { + path: '*', + element: , + }, ]); return ( <> diff --git a/src/assets/404_page.png b/src/assets/404_page.png new file mode 100644 index 0000000..a33c594 Binary files /dev/null and b/src/assets/404_page.png differ diff --git a/src/components/dashboard/ConfirmDisableModal.tsx b/src/components/dashboard/ConfirmDisableModal.tsx new file mode 100644 index 0000000..43a131d --- /dev/null +++ b/src/components/dashboard/ConfirmDisableModal.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +interface ConfirmDisableModalProps { + sellerName: string; + onClose: () => void; + onConfirm: () => void; +} + +const ConfirmDisableModal: React.FC = ({ sellerName, onClose, onConfirm }) => { + const handleBackgroundClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+
+

Confirm Disable

+

+ Are you sure you want to disable the seller account for {sellerName}?
+
+ This action will change their role to buyer. +

+
+ + +
+
+
+ ); +}; + +export default ConfirmDisableModal; \ No newline at end of file diff --git a/src/components/dashboard/RoleChangeModal.tsx b/src/components/dashboard/RoleChangeModal.tsx new file mode 100644 index 0000000..6c98b65 --- /dev/null +++ b/src/components/dashboard/RoleChangeModal.tsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react'; + +interface Role { + id: string; + name: string; +} + +interface RoleChangeModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (roleId: string) => void; + user: any; + roles: Role[]; +} + +const RoleChangeModal: React.FC = ({ isOpen, onClose, onConfirm, user, roles }) => { + const [selectedRoleId, setSelectedRoleId] = useState(''); + + if (!isOpen || !user) return null; + + const handleConfirm = () => { + if (selectedRoleId) { + onConfirm(selectedRoleId); + } + onClose(); + }; + + return ( +
+
+

+ Change Role for{' '} + + {user.firstName} {user.lastName} + +

+

Current Role: {user.Role.name}

+
+ + +
+
+ + +
+
+
+ ); +}; + +export default RoleChangeModal; diff --git a/src/components/dashboard/Sidebar.tsx b/src/components/dashboard/Sidebar.tsx index 786aba3..3f36707 100644 --- a/src/components/dashboard/Sidebar.tsx +++ b/src/components/dashboard/Sidebar.tsx @@ -1,4 +1,4 @@ -import { FaRegListAlt, FaUserFriends, FaUserTie, FaRegEnvelope, FaCog } from 'react-icons/fa'; +import { FaRegListAlt, FaUserFriends, FaUserTie, FaRegEnvelope, FaCog, FaUsers } from 'react-icons/fa'; import { FiLogOut } from 'react-icons/fi'; import { RxDashboard } from 'react-icons/rx'; import logo from '../../assets/Rectangle 2487.png'; @@ -97,6 +97,19 @@ export default function Sidebar({ isOpen, toggleSidebar }: { isOpen: boolean; to Messages +
  • + + `flex items-center py-2.5 px-4 rounded transition duration-200 hover:bg-[#E5E7EB] ${ + isActive ? 'bg-skyBlue text-skyBlueText hover:bg-skyBlue' : 'text-[#8F8183]' + }` + } + > + + Users + +
  • React.ReactNode; +} + +interface TableProps { + data: any[]; + columns: Column[]; + itemsPerPage: number; +} + +const getInitials = (firstName: string, lastName: string) => + `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); + +const getRandomColor = () => { + const letters = '0123456789ABCDEF'; + let color = '#'; + for (let i = 0; i < 6; i++) { + color += letters[Math.floor(Math.random() * 16)]; + } + return color; +}; + +const Table: React.FC = ({ data, columns, itemsPerPage }) => { + const [currentPage, setCurrentPage] = useState(1); + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + const [searchTerm, setSearchTerm] = useState(''); + + const filteredData = useMemo( + () => + data.filter(item => + columns.some(column => String(item[column.key]).toLowerCase().includes(searchTerm.toLowerCase())) + ), + [data, columns, searchTerm] + ); + + const sortedData = useMemo(() => { + if (!sortColumn) return filteredData; + return [...filteredData].sort((a, b) => { + if (a[sortColumn] < b[sortColumn]) return sortDirection === 'asc' ? -1 : 1; + if (a[sortColumn] > b[sortColumn]) return sortDirection === 'asc' ? 1 : -1; + return 0; + }); + }, [filteredData, sortColumn, sortDirection]); + + const paginatedData = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + return sortedData.slice(startIndex, startIndex + itemsPerPage); + }, [sortedData, currentPage, itemsPerPage]); + + const totalPages = Math.ceil(sortedData.length / itemsPerPage); + + const handleSort = (column: string) => { + if (column === sortColumn) { + setSortDirection(prev => (prev === 'asc' ? 'desc' : 'asc')); + } else { + setSortColumn(column); + setSortDirection('asc'); + } + }; + + const handleSearch = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value); + setCurrentPage(1); + }; + + const renderCell = (item: any, column: Column) => { + if (column.isImage) { + return item[column.key] ? ( + {`${item.firstName} + ) : ( +
    + {getInitials(item.firstName, item.lastName)} +
    + ); + } + if (column.render) return column.render(item); + return column.key.includes('.') + ? column.key.split('.').reduce((obj, key) => obj && obj[key], item) + : item[column.key]; + }; + + return ( +
    +
    + +
    + + + + {columns.map(column => ( + + ))} + + + + {paginatedData.map((item, index) => ( + + {columns.map(column => ( + + ))} + + ))} + +
    !column.isImage && handleSort(column.key)} + className={`pl-4 py-4 text-left text-sm font-bold text-[#6B7280] uppercase tracking-wider ${!column.isImage ? 'cursor-pointer' : ''}`} + > + {column.label} + {!column.isImage && sortColumn === column.key && {sortDirection === 'asc' ? ' ▲' : ' ▼'}} +
    + {renderCell(item, column)} +
    +
    +
    + Showing {(currentPage - 1) * itemsPerPage + 1} to {Math.min(currentPage * itemsPerPage, sortedData.length)} of{' '} + {sortedData.length} entries +
    +
    + + +
    +
    +
    + ); +}; + +export default Table; diff --git a/src/components/dashboard/VendorsTable.tsx b/src/components/dashboard/VendorsTable.tsx new file mode 100644 index 0000000..39b6398 --- /dev/null +++ b/src/components/dashboard/VendorsTable.tsx @@ -0,0 +1,159 @@ +import React, { useState, useMemo } from 'react'; + +interface Column { + key: string; + label: string; + isImage?: boolean; + render?: (item: any) => React.ReactNode; +} + +interface TableProps { + data: any[]; + columns: Column[]; + itemsPerPage: number; +} + +const getInitials = (firstName: string, lastName: string) => + `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); + +const getRandomColor = () => { + const letters = '0123456789ABCDEF'; + let color = '#'; + for (let i = 0; i < 6; i++) { + color += letters[Math.floor(Math.random() * 16)]; + } + return color; +}; + +const Table: React.FC = ({ data, columns, itemsPerPage }) => { + const [currentPage, setCurrentPage] = useState(1); + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + const [searchTerm, setSearchTerm] = useState(''); + + const filteredData = useMemo( + () => + data.filter(item => + columns.some(column => String(item[column.key]).toLowerCase().includes(searchTerm.toLowerCase())) + ), + [data, columns, searchTerm] + ); + + const sortedData = useMemo(() => { + if (!sortColumn) return filteredData; + return [...filteredData].sort((a, b) => { + if (a[sortColumn] < b[sortColumn]) return sortDirection === 'asc' ? -1 : 1; + if (a[sortColumn] > b[sortColumn]) return sortDirection === 'asc' ? 1 : -1; + return 0; + }); + }, [filteredData, sortColumn, sortDirection]); + + const paginatedData = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + return sortedData.slice(startIndex, startIndex + itemsPerPage); + }, [sortedData, currentPage, itemsPerPage]); + + const totalPages = Math.ceil(sortedData.length / itemsPerPage); + + const handleSort = (column: string) => { + if (column === sortColumn) { + setSortDirection(prev => (prev === 'asc' ? 'desc' : 'asc')); + } else { + setSortColumn(column); + setSortDirection('asc'); + } + }; + + const handleSearch = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value); + setCurrentPage(1); + }; + + const renderCell = (item: any, column: Column) => { + if (column.isImage) { + return item[column.key] ? ( + {`${item.firstName} + ) : ( +
    + {getInitials(item.firstName, item.lastName)} +
    + ); + } + if (column.render) return column.render(item); + return column.key.includes('.') + ? column.key.split('.').reduce((obj, key) => obj && obj[key], item) + : item[column.key]; + }; + + return ( +
    +
    + +
    + + + + {columns.map(column => ( + + ))} + + + + {paginatedData.map((item, index) => ( + + {columns.map(column => ( + + ))} + + ))} + +
    !column.isImage && handleSort(column.key)} + className={`pl-4 py-4 text-left text-sm font-bold text-[#6B7280] uppercase tracking-wider ${!column.isImage ? 'cursor-pointer' : ''}`} + > + {column.label} + {!column.isImage && sortColumn === column.key && {sortDirection === 'asc' ? ' ▲' : ' ▼'}} +
    + {renderCell(item, column)} +
    +
    +
    + Showing {(currentPage - 1) * itemsPerPage + 1} to {Math.min(currentPage * itemsPerPage, sortedData.length)} of{' '} + {sortedData.length} entries +
    +
    + + +
    +
    +
    + ); +}; + +export default Table; diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..47ea344 --- /dev/null +++ b/src/pages/NotFoundPage.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import photo404 from '../assets/404_page.png'; +import { FaHome, FaArrowLeft } from 'react-icons/fa'; + +const NotFoundPage: React.FC = () => { + const navigate = useNavigate(); + + return ( +
    +
    + page not found +

    Not Found

    +

    + Oops! ... Looks like this page does not exist in MAVERICKS +

    +
    + + {/* Add Home icon */} + Go Home + + +
    +
    +
    + ); +}; + +export default NotFoundPage; diff --git a/src/pages/admin/Sellers.tsx b/src/pages/admin/Sellers.tsx index 3651d71..f8bee62 100644 --- a/src/pages/admin/Sellers.tsx +++ b/src/pages/admin/Sellers.tsx @@ -1,3 +1,112 @@ -export default function Sellers() { - return
    Sellers
    ; -} +// src/pages/admin/Sellers.tsx +import React, { useState, useEffect } from 'react'; +import Table from '../../components/dashboard/VendorsTable'; +import { BiLoader } from 'react-icons/bi'; +import { useGetRolesQuery } from '../../services/roleApi'; +import { useGetSellersQuery, useUpdateUserRoleMutation } from '../../services/userApi'; +import ConfirmDisableModal from '../../components/dashboard/ConfirmDisableModal'; +import { User as Seller } from '../../types/Types'; + +const Sellers: React.FC = () => { + const { + data: sellersData, + isLoading: sellersLoading, + isError: sellersError, + refetch: refetchSellers, + } = useGetSellersQuery(undefined, { + pollingInterval: 30000, + }); + const { data: rolesData, isLoading: rolesLoading, isError: rolesError } = useGetRolesQuery(); + const [updateUserRole] = useUpdateUserRoleMutation(); + + const [selectedSeller, setSelectedSeller] = useState(null); + + const loading = sellersLoading || rolesLoading; + const error = sellersError || rolesError; + + const sellers = sellersData?.message || []; + const roles = rolesData?.data || []; + + useEffect(() => { + refetchSellers(); + }, [refetchSellers]); + + const handleOpenModal = (seller: Seller) => { + setSelectedSeller(seller); + }; + + const handleCloseModal = () => { + setSelectedSeller(null); + }; + + const handleDisableSeller = async () => { + if (selectedSeller) { + try { + const buyerRole = roles.find(role => role.name.toLowerCase() === 'buyer'); + if (!buyerRole) { + console.error('Buyer role not found'); + return; + } + await updateUserRole({ userId: selectedSeller.id, roleId: buyerRole.id }).unwrap(); + refetchSellers(); + handleCloseModal(); + } catch (error) { + console.error('Failed to disable seller:', error); + } + } + }; + + const columns = [ + { key: 'photoUrl', label: 'Photo', isImage: true, sortable: false }, + { key: 'firstName', label: 'First Name', sortable: true }, + { key: 'lastName', label: 'Last Name', sortable: true }, + { key: 'email', label: 'Email', sortable: true }, + { key: 'phoneNumber', label: 'Phone', sortable: true }, + { key: 'gender', label: 'Gender', sortable: true }, + { + key: 'Role.name', + label: 'Role', + render: (seller: Seller) => seller.Role.name, + sortable: false, + }, + { + key: 'action', + label: 'Action', + render: (seller: Seller) => ( + + ), + sortable: false, + }, + ]; + + if (loading) + return ( +
    + +
    + ); + if (error) { + return
    Error fetching data. Please try again.
    ; + } + + return ( +
    +

    Seller Management

    + + {selectedSeller && ( + + )} + + ); +}; + +export default Sellers; diff --git a/src/pages/admin/UserManagement.tsx b/src/pages/admin/UserManagement.tsx new file mode 100644 index 0000000..d3d022d --- /dev/null +++ b/src/pages/admin/UserManagement.tsx @@ -0,0 +1,112 @@ +// src/pages/admin/UserManagement.tsx +import React, { useState, useEffect } from 'react'; +import Table from '../../components/dashboard/UsersTable'; +import { BiLoader } from 'react-icons/bi'; +import { useGetRolesQuery } from '../../services/roleApi'; +import { useGetUsersQuery, useUpdateUserRoleMutation } from '../../services/userApi'; +import RoleChangeModal from '../../components/dashboard/RoleChangeModal'; +import { User } from '../../types/Types'; + +const UserManagement: React.FC = () => { + const { data: usersData, isLoading: usersLoading, isError: usersError } = useGetUsersQuery(); + const { data: rolesData, isLoading: rolesLoading, isError: rolesError } = useGetRolesQuery(); + const [updateUserRole] = useUpdateUserRoleMutation(); + + const [users, setUsers] = useState([]); + const [selectedUser, setSelectedUser] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + + useEffect(() => { + if (usersData?.message) { + setUsers(usersData.message); + } + }, [usersData]); + + const loading = usersLoading || rolesLoading; + const error = usersError || rolesError; + + const roles = rolesData?.data || []; + + const handleOpenModal = (user: User) => { + setSelectedUser(user); + setIsModalOpen(true); + }; + + const handleCloseModal = () => { + setSelectedUser(null); + setIsModalOpen(false); + }; + + const handleRoleChange = async (newRoleId: string) => { + if (selectedUser) { + try { + await updateUserRole({ userId: selectedUser.id, roleId: newRoleId }).unwrap(); + setUsers(prevUsers => + prevUsers.map(user => + user.id === selectedUser.id + ? { + ...user, + Role: { ...user.Role, id: newRoleId, name: roles.find(r => r.id === newRoleId)?.name || '' }, + } + : user + ) + ); + handleCloseModal(); + } catch (error) { + console.error('Failed to update user role:', error); + } + } + }; + + const columns = [ + { key: 'photoUrl', label: 'Photo', isImage: true }, + { key: 'firstName', label: 'First Name' }, + { key: 'lastName', label: 'Last Name' }, + { key: 'email', label: 'Email' }, + { key: 'phoneNumber', label: 'Phone' }, + { key: 'gender', label: 'Gender' }, + { + key: 'role', + label: 'Role', + render: (user: User) => user.Role.name + }, + { + key: 'action', + label: 'Action', + render: (user: User) => ( + + ), + }, + ]; + + if (loading) + return ( +
    + +
    + ); + if (error) { + return
    Error fetching data. Please try again.
    ; + } + + return ( +
    +

    User Management

    +
    + + + ); +}; + +export default UserManagement; diff --git a/src/services/roleApi.ts b/src/services/roleApi.ts new file mode 100644 index 0000000..21c4f52 --- /dev/null +++ b/src/services/roleApi.ts @@ -0,0 +1,18 @@ +import { mavericksApi } from '.'; +import { Role } from '../types/Types'; + +export const roleApi = mavericksApi.injectEndpoints({ + endpoints: builder => ({ + getRoles: builder.query<{ data: Role[] }, void>({ + query: () => ({ + url: 'roles', + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + }), + }), + }), + overrideExisting: false, +}); + +export const { useGetRolesQuery } = roleApi; diff --git a/src/services/userApi.ts b/src/services/userApi.ts index 7072975..b6b8c74 100644 --- a/src/services/userApi.ts +++ b/src/services/userApi.ts @@ -1,4 +1,5 @@ import { mavericksApi } from '.'; +import { User } from '../types/Types'; export const userApi = mavericksApi.injectEndpoints({ endpoints: builder => ({ @@ -7,8 +8,35 @@ export const userApi = mavericksApi.injectEndpoints({ url: `users/user/${id}`, }), }), + getUsers: builder.query<{ message: User[] }, void>({ + query: () => ({ + url: 'users', + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}`, + }, + }), + }), + getSellers: builder.query({ + query: () => ({ + url: 'users/role/seller', + headers: { + Authorization: localStorage.getItem('token') || '', + }, + }), + }), + updateUserRole: builder.mutation({ + query: ({ userId, roleId }) => ({ + url: `users/role/${userId}`, + method: 'PUT', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json', + }, + body: { roleId }, + }), + }), }), overrideExisting: false, }); -export const { useGetUserByIdQuery } = userApi; +export const { useGetUserByIdQuery, useGetUsersQuery,useGetSellersQuery, useUpdateUserRoleMutation } = userApi; diff --git a/src/types/Types.ts b/src/types/Types.ts index 25aeaa0..ec6df7f 100644 --- a/src/types/Types.ts +++ b/src/types/Types.ts @@ -54,3 +54,21 @@ export interface NotificationProps { updatedAt: string; isRead: boolean; } + +export interface User { + id: string; + firstName: string; + lastName: string; + email: string; + phoneNumber: string; + photoUrl: string | null; + gender: string; + Role: { + name: string; + }; +}; + +export interface Role { + id: string; + name: string; +} \ No newline at end of file