From 1d78b2b65dce837a9f5b1f6f933746aa7562cb40 Mon Sep 17 00:00:00 2001 From: AmirReza Jamali Date: Sun, 21 Sep 2025 13:18:53 +0330 Subject: [PATCH] feat(admins): implement admin status management This commit introduces the functionality for super admins to change the status of other admin users to either 'Active' or 'Inactive'. This provides better control over admin account access. Key changes include: - A 'Status' column with a visual badge has been added to the admins table. - A new 'Change Status' action button, using a new `UserSettingIcon`, opens a modal for status updates. - The backend service and frontend mutation for updating an admin's status are implemented. - The `IUser` type and a new `AdminStatus` enum have been added to support this feature. Additionally, the confirmation modal implementation has been simplified across the admin and customer tables by removing the `modalConfig` state. --- src/assets/icons.tsx | 14 +++ .../admins/ChangeStatusModalContent.tsx | 43 +++++++ src/components/Tables/admins/index.tsx | 78 +++++++++---- .../Tables/admins/useAdminsPresenter.ts | 109 +++++++++--------- src/components/Tables/companies/index.tsx | 3 + .../companies/useCompanyListPresenter.ts | 4 +- src/components/Tables/customers/index.tsx | 27 +++-- .../customers/useCustomerListPresenter.ts | 53 +-------- src/components/Tables/tenders/index.tsx | 28 ++--- .../Tables/tenders/useTenderListPresenter.ts | 10 +- src/components/ui/Status.tsx | 2 +- src/components/ui/modal.tsx | 11 +- src/constants/enums.ts | 5 + src/hooks/queries/useUsersQueries.ts | 24 ++++ src/lib/api/endpoints.ts | 3 +- src/lib/api/services/user-services.ts | 17 +++ src/lib/api/types/User.ts | 8 +- src/utils/shared.ts | 8 +- 18 files changed, 274 insertions(+), 173 deletions(-) create mode 100644 src/components/Tables/admins/ChangeStatusModalContent.tsx diff --git a/src/assets/icons.tsx b/src/assets/icons.tsx index eed4ddc..7c3406b 100644 --- a/src/assets/icons.tsx +++ b/src/assets/icons.tsx @@ -215,6 +215,20 @@ export function GlobeIcon(props: IconProps) { ); } +export function UserSettingIcon(props: IconProps) { + return ( + + + + ); +} export function TrashIcon(props: IconProps) { return ( >; +} +const ChangeStatusModalContent: FC = ({ status, setStatus }) => { + const { register } = useForm({ + mode: "onChange", + defaultValues: { status }, + }); + const adminStatusEntries = Object.entries(AdminStatus).map( + ([key, value]) => ({ + label: capitalize(key), + value, + }), + ); + + return ( +
+ +
+ ); +}; + +export default ChangeStatusModalContent; diff --git a/src/components/Tables/admins/index.tsx b/src/components/Tables/admins/index.tsx index 1442972..92720a5 100644 --- a/src/components/Tables/admins/index.tsx +++ b/src/components/Tables/admins/index.tsx @@ -1,7 +1,9 @@ "use client"; -import { PencilSquareIcon, TrashIcon } from "@/assets/icons"; +import { PencilSquareIcon, TrashIcon, UserSettingIcon } from "@/assets/icons"; import ConfirmationModal from "@/components/ui/ConfirmationModal"; +import Modal from "@/components/ui/modal"; +import Status from "@/components/ui/Status"; import { Table, TableBody, @@ -11,7 +13,9 @@ import { TableRow, } from "@/components/ui/table"; import TableSkeleton from "@/components/ui/TableSkeleton"; +import { AdminStatus } from "@/constants/enums"; import Link from "next/link"; +import ChangeStatusModalContent from "./ChangeStatusModalContent"; import { useAdminsPresenter } from "./useAdminsPresenter"; const AdminsTable = () => { @@ -22,9 +26,15 @@ const AdminsTable = () => { onConfirmDelete, isModalOpen, setIsModalOpen, - modalConfig, + isChangeStatusModalOpen, + setIsChangeStatusModalOpen, + columns, setCurrentUser, pathName, + changeStatus, + currentUser, + setStatus, + status, } = useAdminsPresenter(); return ( @@ -35,31 +45,24 @@ const AdminsTable = () => {
- - Full name - - - Email - - - Role - - - Username - - - Actions - + {columns.map((column) => ( + + {column} + + ))} - {isPending && } + {isPending && } - {allUsers.map((item) => { + {allUsers.map((item, index) => { return ( + + {index + 1} + {item.full_name} @@ -72,6 +75,9 @@ const AdminsTable = () => { {item.username} + + {item.status} +
@@ -85,6 +91,16 @@ const AdminsTable = () => { Delete Admin +
+ setIsChangeStatusModalOpen(false)} + onConfirm={() => { + changeStatus({ id: currentUser?.id!, credentials: { status } }); + }} + > + + {isModalOpen && ( { onConfirmDelete(); }} diff --git a/src/components/Tables/admins/useAdminsPresenter.ts b/src/components/Tables/admins/useAdminsPresenter.ts index 0f52de6..8f63241 100644 --- a/src/components/Tables/admins/useAdminsPresenter.ts +++ b/src/components/Tables/admins/useAdminsPresenter.ts @@ -1,84 +1,83 @@ "use client"; -import { useAdminListInfiniteQuery, useDeleteAdmin } from "@/hooks/queries"; -import useDebounce from "@/hooks/useDebounce"; -import { useState, useEffect, useRef } from "react"; -import { z } from "zod"; -import { useInView } from "react-intersection-observer"; -import { IUser } from "@/lib/api"; -import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal"; +import { apiDefaultParams } from "@/constants/Api_Params"; +import { AdminStatus } from "@/constants/enums"; +import { + useAdminListInfiniteQuery, + useChangeAdminStatus, + useDeleteAdmin, +} from "@/hooks/queries"; +import { IUser, TChangeAdminStatusCredentials } from "@/lib/api"; import { usePathname, useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; + export const useAdminsPresenter = () => { + const [isChangeStatusModalOpen, setIsChangeStatusModalOpen] = useState(false); const [currentUser, setCurrentUser] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); + const [status, setStatus] = useState( + currentUser?.status ?? AdminStatus.ACTIVE, + ); const router = useRouter(); - const [modalConfig, setModalConfig] = useState< - Partial - >({}); - - const [search, setSearch] = useState(""); - - const [params, setParams] = useState>({ - sort: "created_at", - }); - const debouncedSearch = useDebounce(search, 1000); - const pathName = usePathname(); const { - data, - error, - isPending, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - } = useAdminListInfiniteQuery(params); + register, + formState: { errors }, + handleSubmit, + } = useForm({ + mode: "onChange", + defaultValues: { + status: currentUser?.status, + }, + }); + const [params, setParams] = useState>({ + ...apiDefaultParams, + }); + const pathName = usePathname(); + const { data, isPending } = useAdminListInfiniteQuery(params); const { mutate: deleteAdmin } = useDeleteAdmin(() => { setIsModalOpen(false); }); - const { inView } = useInView({ threshold: 0.5 }); - - useEffect(() => { - if (inView && hasNextPage && data && data.pages.length < 5) { - fetchNextPage(); - } - }, [inView, hasNextPage, data, fetchNextPage]); - - useEffect(() => { - const result = z.string().safeParse(debouncedSearch); - if (result.success) { - setParams((prevParams) => ({ - ...prevParams, - })); - } - }, [debouncedSearch]); - - const handleFilterChange = (e: React.ChangeEvent) => { - setSearch(e.target.value); - }; const onConfirmDelete = () => { if (currentUser) { deleteAdmin(currentUser.id); } }; + const { mutate: changeStatus } = useChangeAdminStatus({ + id: currentUser?.id ?? "", + successCallback: () => { + setIsChangeStatusModalOpen(false); + }, + }); const allUsers = data?.pages.flatMap((page) => page.data.users) ?? []; + const columns = [ + "row", + "full name", + "email", + "role", + "username", + "status", + "actions", + ]; return { allUsers, - currentAdmin: currentUser, - error, - handleFilterChange, - isFetchingNextPage, - fetchNextPage, + columns, isPending, onConfirmDelete, isModalOpen, setIsModalOpen, - modalConfig, - setModalConfig, - search, - hasNextPage, - data, router, setCurrentUser, pathName, + status, + setStatus, + changeStatus, + isChangeStatusModalOpen, + currentUser, + setIsChangeStatusModalOpen, + register, + errors, + handleSubmit, }; }; diff --git a/src/components/Tables/companies/index.tsx b/src/components/Tables/companies/index.tsx index f94cda6..f3c11de 100644 --- a/src/components/Tables/companies/index.tsx +++ b/src/components/Tables/companies/index.tsx @@ -58,6 +58,9 @@ const CompaniesTable = ({}: IProps) => { key={company.id} className="odd:bg-gray-2 dark:odd:bg-gray-7" > + + {index + 1} + {company.name} diff --git a/src/components/Tables/companies/useCompanyListPresenter.ts b/src/components/Tables/companies/useCompanyListPresenter.ts index 3f82769..3be8313 100644 --- a/src/components/Tables/companies/useCompanyListPresenter.ts +++ b/src/components/Tables/companies/useCompanyListPresenter.ts @@ -6,8 +6,7 @@ import { } from "@/hooks/queries"; import useDebounce from "@/hooks/useDebounce"; import { TCompany } from "@/lib/api"; -import { usePathname } from "next/navigation"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { ChangeEvent, useEffect, useRef, useState } from "react"; import { useInView } from "react-intersection-observer"; import { z } from "zod"; @@ -26,6 +25,7 @@ export const useCompanyListPresenter = () => { const [isModalOpen, setIsModalOpen] = useState(false); const router = useRouter(); const columns = [ + "row", "name", "email", "phone", diff --git a/src/components/Tables/customers/index.tsx b/src/components/Tables/customers/index.tsx index a02da66..79c303f 100644 --- a/src/components/Tables/customers/index.tsx +++ b/src/components/Tables/customers/index.tsx @@ -1,5 +1,5 @@ "use client"; -import { PencilSquareIcon, TrashIcon, UserIcon } from "@/assets/icons"; +import { PencilSquareIcon, TrashIcon } from "@/assets/icons"; import ConfirmationModal from "@/components/ui/ConfirmationModal"; import { Table, @@ -12,6 +12,7 @@ import { import Link from "next/link"; import useCustomerListPresenter from "./useCustomerListPresenter"; +import { Building } from "@/components/Layouts/sidebar/icons"; import Modal from "@/components/ui/modal"; import Status from "@/components/ui/Status"; import TableSkeleton from "@/components/ui/TableSkeleton"; @@ -26,7 +27,6 @@ const CustomersTable = () => { router, columns, allCustomers, - modalConfig, isModalOpen, assignSelectedCompanies, selectedCompanies, @@ -59,8 +59,9 @@ const CustomersTable = () => { {isPending && } - {allCustomers.map((customer) => ( + {allCustomers.map((customer, index) => ( + {index + 1} {customer.full_name} {customer.email} @@ -71,7 +72,7 @@ const CustomersTable = () => { {customer.type} - {customer.status} + {customer.status} {customer.companies?.length ? ( @@ -82,7 +83,9 @@ const CustomersTable = () => { None )} - {customer.role ?? "-"} + + {customer.role ?? "-"} +
@@ -139,12 +142,12 @@ const CustomersTable = () => { {isModalOpen && ( { onConfirmDelete(); }} diff --git a/src/components/Tables/customers/useCustomerListPresenter.ts b/src/components/Tables/customers/useCustomerListPresenter.ts index 4a5facd..9cc641c 100644 --- a/src/components/Tables/customers/useCustomerListPresenter.ts +++ b/src/components/Tables/customers/useCustomerListPresenter.ts @@ -1,53 +1,30 @@ "use client"; -import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal"; import { useAssignCompany, useCustomersInfiniteQuery, useDeleteCustomerQuery, } from "@/hooks/queries"; -import useDebounce from "@/hooks/useDebounce"; import { TAssignCustomerToCompanyCredentials, TCustomer } from "@/lib/api"; import { usePathname, useRouter } from "next/navigation"; -import { ChangeEvent, useEffect, useRef, useState } from "react"; -import { useInView } from "react-intersection-observer"; -import { z } from "zod"; +import { useState } from "react"; const useCustomerListPresenter = () => { - const deleteModal = useRef(null); const [isAssignCompanyModalOpen, setIsAssignCompanyModalOpen] = useState(false); - const { inView } = useInView({ threshold: 0.5 }); const [selectedCompanies, setSelectedCompanies] = useState(null); const [currentCustomer, setCurrentCustomer] = useState( null, ); - const [modalConfig, setModalConfig] = useState< - Partial - >({}); + const [isModalOpen, setIsModalOpen] = useState(false); const pathName = usePathname(); - const [search, setSearch] = useState(""); const [params, setParams] = useState>({ sort: "created_at", }); const router = useRouter(); - const debouncedSearch = useDebounce(search, 1000); - const { - data, - error, - isPending, - isError, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - } = useCustomersInfiniteQuery(params); - useEffect(() => { - if (inView && hasNextPage && data && data.pages.length < 5) { - fetchNextPage(); - } - }, [inView, hasNextPage, data, fetchNextPage]); + const { data, isPending } = useCustomersInfiniteQuery(params); const { mutate: deleteCustomer } = useDeleteCustomerQuery(() => setIsModalOpen(false), ); @@ -57,18 +34,8 @@ const useCustomerListPresenter = () => { setIsAssignCompanyModalOpen(false); }, ); - useEffect(() => { - const result = z.string().safeParse(debouncedSearch); - if (result.success) { - setParams((prevParams) => ({ - ...prevParams, - })); - } - }, [debouncedSearch]); - const handleFilterChange = (e: ChangeEvent) => { - setSearch(e.target.value); - }; const columns = [ + "row", "full name", "email", "created at", @@ -85,20 +52,10 @@ const useCustomerListPresenter = () => { }; const customers = data?.pages.flatMap((page) => page.data.customers) ?? []; return { - hasNextPage, - fetchNextPage, allCustomers: customers, currentCustomer, - deleteCustomer, - deleteModal, - error, - handleFilterChange, - isError, - isFetchingNextPage, columns, - data, isPending, - search, setCurrentCustomer, selectedCompanies, setSelectedCompanies, @@ -108,8 +65,6 @@ const useCustomerListPresenter = () => { assignSelectedCompanies, pathName, router, - modalConfig, - setModalConfig, isModalOpen, setIsModalOpen, }; diff --git a/src/components/Tables/tenders/index.tsx b/src/components/Tables/tenders/index.tsx index aa43cff..ebd76b4 100644 --- a/src/components/Tables/tenders/index.tsx +++ b/src/components/Tables/tenders/index.tsx @@ -17,7 +17,7 @@ import TableSkeleton from "@/components/ui/TableSkeleton"; import { cn } from "@/lib/utils"; import Link from "next/link"; const TendersTable = () => { - const { allTenders, isPending, router, pathName, setParams } = + const { allTenders, isPending, router, pathName, setParams, columns } = useTenderListPresenter(); return ( @@ -25,29 +25,15 @@ const TendersTable = () => { - - Row - - - Title - - - Country Code - - - Submission Url - - - Status - - - - Actions - + {columns.map((column) => ( + + {column} + + ))} - {isPending && } + {isPending && } {allTenders?.data.tenders?.map((item, index) => !item ? ( diff --git a/src/components/Tables/tenders/useTenderListPresenter.ts b/src/components/Tables/tenders/useTenderListPresenter.ts index 9c43feb..41f3b47 100644 --- a/src/components/Tables/tenders/useTenderListPresenter.ts +++ b/src/components/Tables/tenders/useTenderListPresenter.ts @@ -34,9 +34,17 @@ const useTenderListPresenter = () => { const handleFilterChange = (e: React.ChangeEvent) => { setSearch(e.target.value); }; - + const columns = [ + "row", + "title", + "country code", + "submission url", + "status", + "actions", + ]; return { currentTender, + columns, setCurrentTender, search, handleFilterChange, diff --git a/src/components/ui/Status.tsx b/src/components/ui/Status.tsx index 24e00d1..4684e37 100644 --- a/src/components/ui/Status.tsx +++ b/src/components/ui/Status.tsx @@ -26,7 +26,7 @@ const Status = ({ status, children }: IProps) => { const blues = ["awarded", "processing", "in-review", "scheduled"]; const yellows = ["pending", "warning", "on-hold", "delayed"]; const oranges = ["expired", "expiring", "limited"]; - const grays = ["draft", "disabled", "archived", "paused"]; + const grays = ["draft", "disabled", "archived", "paused", "suspended"]; return ( = ({ onConfirm, }) => { const [mounted, setMounted] = useState(false); - + const isMutating = useIsMutating(); useEffect(() => { setMounted(true); }, []); @@ -57,7 +58,11 @@ const Modal: React.FC = ({ className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium text-white hover:bg-opacity-90" onClick={onConfirm} > - Confirm + {isMutating ? ( +
+ ) : ( + "Confirm" + )}