Merge pull request 'feat(admins): implement admin status management' (#33) from change-admin-status into develop

Reviewed-on: https://repo.ravanertebat.com/TM/tm_panel/pulls/33
Reviewed-by: Nima Nakhsotin <n.nakhostin@ravanertebat.com>
This commit is contained in:
Nima Nakhsotin
2025-09-21 13:23:53 +03:30
18 changed files with 274 additions and 173 deletions
+14
View File
@@ -215,6 +215,20 @@ export function GlobeIcon(props: IconProps) {
);
}
export function UserSettingIcon(props: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 -960 960 960"
width="24px"
fill="currentColor"
{...props}
>
<path d="M400-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM80-160v-112q0-33 17-62t47-44q51-26 115-44t141-18h14q6 0 12 2-8 18-13.5 37.5T404-360h-4q-71 0-127.5 18T180-306q-9 5-14.5 14t-5.5 20v32h252q6 21 16 41.5t22 38.5H80Zm560 40-12-60q-12-5-22.5-10.5T584-204l-58 18-40-68 46-40q-2-14-2-26t2-26l-46-40 40-68 58 18q11-8 21.5-13.5T628-460l12-60h80l12 60q12 5 22.5 11t21.5 15l58-20 40 70-46 40q2 12 2 25t-2 25l46 40-40 68-58-18q-11 8-21.5 13.5T732-180l-12 60h-80Zm40-120q33 0 56.5-23.5T760-320q0-33-23.5-56.5T680-400q-33 0-56.5 23.5T600-320q0 33 23.5 56.5T680-240ZM400-560q33 0 56.5-23.5T480-640q0-33-23.5-56.5T400-720q-33 0-56.5 23.5T320-640q0 33 23.5 56.5T400-560Zm0-80Zm12 400Z" />
</svg>
);
}
export function TrashIcon(props: IconProps) {
return (
<svg
@@ -0,0 +1,43 @@
import { Select } from "@/components/FormElements/select";
import { AdminStatus } from "@/constants/enums";
import { FormErrorMessages } from "@/constants/Texts";
import { TChangeAdminStatusCredentials } from "@/lib/api";
import { capitalize } from "@/utils/shared";
import { Dispatch, FC, SetStateAction } from "react";
import { useForm } from "react-hook-form";
interface IProps {
status: AdminStatus;
setStatus: Dispatch<SetStateAction<string>>;
}
const ChangeStatusModalContent: FC<IProps> = ({ status, setStatus }) => {
const { register } = useForm<TChangeAdminStatusCredentials>({
mode: "onChange",
defaultValues: { status },
});
const adminStatusEntries = Object.entries(AdminStatus).map(
([key, value]) => ({
label: capitalize(key),
value,
}),
);
return (
<form>
<Select
{...register("status", {
required: { value: true, message: FormErrorMessages.required },
onChange(event) {
setStatus(event.target.value);
},
})}
items={adminStatusEntries}
label="Status"
name="status"
defaultValue={status}
placeholder="Please select new Status"
/>
</form>
);
};
export default ChangeStatusModalContent;
+52 -24
View File
@@ -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 = () => {
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
<TableHead colSpan={100} className="text-start font-extrabold">
Full name
</TableHead>
<TableHead colSpan={100} className="text-start font-extrabold">
Email
</TableHead>
<TableHead colSpan={100} className="text-start font-extrabold">
Role
</TableHead>
<TableHead colSpan={100} className="text-start font-extrabold">
Username
</TableHead>
<TableHead colSpan={100} className="text-start font-extrabold">
Actions
{columns.map((column) => (
<TableHead key={column} colSpan={100}>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending && <TableSkeleton column={5} />}
{isPending && <TableSkeleton column={columns.length} />}
<TableBody>
{allUsers.map((item) => {
{allUsers.map((item, index) => {
return (
<TableRow
key={item.id}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{index + 1}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{item.full_name}
</TableCell>
@@ -72,6 +75,9 @@ const AdminsTable = () => {
<TableCell className="text-start" colSpan={100}>
{item.username}
</TableCell>
<TableCell className="text-start" colSpan={100}>
<Status status={item.status ?? ""}>{item.status}</Status>
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
@@ -85,6 +91,16 @@ const AdminsTable = () => {
<span className="sr-only">Delete Admin </span>
<TrashIcon />
</button>
<button
className="hover:text-yellow-dark"
onClick={() => {
setCurrentUser(item);
setIsChangeStatusModalOpen(true);
}}
>
<span className="sr-only">Change Admin Status</span>
<UserSettingIcon />
</button>
<button
className="hover:text-primary"
onClick={() => router.push(`/admins/edit/${item.id}`)}
@@ -99,15 +115,27 @@ const AdminsTable = () => {
})}
</TableBody>
</Table>
<Modal
isOpen={isChangeStatusModalOpen}
onClose={() => setIsChangeStatusModalOpen(false)}
onConfirm={() => {
changeStatus({ id: currentUser?.id!, credentials: { status } });
}}
>
<ChangeStatusModalContent
status={currentUser?.status as AdminStatus}
setStatus={setStatus}
/>
</Modal>
{isModalOpen && (
<ConfirmationModal
isOpen={isModalOpen}
title={modalConfig.title || "Confirm"}
message={modalConfig.message || "Are you sure?"}
variant={modalConfig.variant || "warning"}
size={modalConfig.size || "md"}
confirmText={modalConfig.confirmText ?? "Confirm"}
cancelText={modalConfig.cancelText ?? "Cancel"}
title={"Confirm"}
message={"Are you sure?"}
variant={"warning"}
size={"md"}
confirmText={"Confirm"}
cancelText={"Cancel"}
onConfirm={() => {
onConfirmDelete();
}}
@@ -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<IUser | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [status, setStatus] = useState<string>(
currentUser?.status ?? AdminStatus.ACTIVE,
);
const router = useRouter();
const [modalConfig, setModalConfig] = useState<
Partial<ConfirmationModalProps>
>({});
const [search, setSearch] = useState("");
const [params, setParams] = useState<Record<string, any>>({
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<TChangeAdminStatusCredentials>({
mode: "onChange",
defaultValues: {
status: currentUser?.status,
},
});
const [params, setParams] = useState<Record<string, any>>({
...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<HTMLInputElement>) => {
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,
};
};
@@ -58,6 +58,9 @@ const CompaniesTable = ({}: IProps) => {
key={company.id}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableHead className="text-start" colSpan={100}>
{index + 1}
</TableHead>
<TableHead className="text-start" colSpan={100}>
{company.name}
</TableHead>
@@ -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",
+14 -11
View File
@@ -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 = () => {
</TableHeader>
{isPending && <TableSkeleton column={columns.length} />}
<TableBody>
{allCustomers.map((customer) => (
{allCustomers.map((customer, index) => (
<TableRow key={customer.id}>
<TableCell colSpan={100}>{index + 1}</TableCell>
<TableCell colSpan={100}>{customer.full_name}</TableCell>
<TableCell colSpan={100}>{customer.email}</TableCell>
<TableCell colSpan={100}>
@@ -82,7 +83,9 @@ const CustomersTable = () => {
<span>None</span>
)}
</TableCell>
<TableCell colSpan={100}>{customer.role ?? "-"}</TableCell>
<TableCell colSpan={100} className="uppercase">
{customer.role ?? "-"}
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<div className="flex items-center justify-start gap-x-3.5">
<button
@@ -112,7 +115,7 @@ const CustomersTable = () => {
}}
>
<span className="sr-only">Assign To Company </span>
<UserIcon />
<Building />
</button>
</div>
</TableCell>
@@ -139,12 +142,12 @@ const CustomersTable = () => {
{isModalOpen && (
<ConfirmationModal
isOpen={isModalOpen}
title={modalConfig.title || "Confirm"}
message={modalConfig.message || "Are you sure?"}
variant={modalConfig.variant || "default"}
size={modalConfig.size || "md"}
confirmText={modalConfig.confirmText ?? "Confirm"}
cancelText={modalConfig.cancelText ?? "Cancel"}
title={"Confirm"}
message={"Are you sure?"}
variant={"default"}
size={"md"}
confirmText={"Confirm"}
cancelText={"Cancel"}
onConfirm={() => {
onConfirmDelete();
}}
@@ -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<HTMLDialogElement | null>(null);
const [isAssignCompanyModalOpen, setIsAssignCompanyModalOpen] =
useState(false);
const { inView } = useInView({ threshold: 0.5 });
const [selectedCompanies, setSelectedCompanies] =
useState<TAssignCustomerToCompanyCredentials | null>(null);
const [currentCustomer, setCurrentCustomer] = useState<TCustomer | null>(
null,
);
const [modalConfig, setModalConfig] = useState<
Partial<ConfirmationModalProps>
>({});
const [isModalOpen, setIsModalOpen] = useState(false);
const pathName = usePathname();
const [search, setSearch] = useState("");
const [params, setParams] = useState<Record<string, any>>({
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<HTMLInputElement>) => {
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,
};
+6 -20
View File
@@ -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 = () => {
<Table>
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
<TableHead colSpan={100} className="text-start font-extrabold">
Row
</TableHead>
<TableHead colSpan={100} className="text-start font-extrabold">
Title
</TableHead>
<TableHead colSpan={100} className="text-start font-extrabold">
Country Code
</TableHead>
<TableHead colSpan={100} className="text-start font-extrabold">
Submission Url
</TableHead>
<TableHead colSpan={100} className="text-start font-extrabold">
Status
</TableHead>
<TableHead colSpan={100} className="text-start font-extrabold">
Actions
{columns.map((column) => (
<TableHead key={column} colSpan={100}>
{column}
</TableHead>
))}
</TableRow>
</TableHeader>
{isPending && <TableSkeleton column={6} />}
{isPending && <TableSkeleton column={columns.length} />}
<TableBody>
{allTenders?.data.tenders?.map((item, index) =>
!item ? (
@@ -34,9 +34,17 @@ const useTenderListPresenter = () => {
const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
};
const columns = [
"row",
"title",
"country code",
"submission url",
"status",
"actions",
];
return {
currentTender,
columns,
setCurrentTender,
search,
handleFilterChange,
+1 -1
View File
@@ -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 (
<span
+8 -3
View File
@@ -1,5 +1,6 @@
import { CrossIcon } from "@/assets/icons";
import { useState, useEffect, ReactNode } from "react";
import { useIsMutating } from "@tanstack/react-query";
import { ReactNode, useEffect, useState } from "react";
import { createPortal } from "react-dom";
interface ModalProps {
@@ -16,7 +17,7 @@ const Modal: React.FC<ModalProps> = ({
onConfirm,
}) => {
const [mounted, setMounted] = useState(false);
const isMutating = useIsMutating();
useEffect(() => {
setMounted(true);
}, []);
@@ -57,7 +58,11 @@ const Modal: React.FC<ModalProps> = ({
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 ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
"Confirm"
)}
</button>
<button
type="button"
+5
View File
@@ -5,3 +5,8 @@ export enum TenderStatus {
AWARDED = "awarded",
DRAFT = "draft",
}
export enum AdminStatus {
ACTIVE = "active",
INACTIVE = "inactive",
SUSPENDED = "suspended",
}
+24
View File
@@ -121,3 +121,27 @@ export const useAdminListInfiniteQuery = (params?: Record<string, any>) => {
},
});
};
export const useChangeAdminStatus = ({
id,
successCallback,
}: {
id: string;
successCallback: () => void;
}) => {
const queryClient = useQueryClient();
const mutationKey = useMemo(
() => [API_ENDPOINTS.USER.CHANGE_ADMIN_STATUS(id)],
[id],
);
return useMutation({
mutationKey,
mutationFn: userService.changeAdminStatus,
onSuccess: ({ message }: { message: string }) => {
successCallback();
toast.success(message);
queryClient.invalidateQueries({
queryKey: [API_ENDPOINTS.USER.ADMINS],
});
},
});
};
+2 -1
View File
@@ -9,6 +9,7 @@ export const API_ENDPOINTS = {
REFRESH_TOKEN: "profile/refresh-token",
ADMINS: "users",
ADMIN_DETAILS: (id: string) => `users/${id}`,
CHANGE_ADMIN_STATUS: (id: string) => `users/${id}/status`,
},
PROFILE: {
READ: "profile",
@@ -25,7 +26,7 @@ export const API_ENDPOINTS = {
UPDATE: (id: string) => `company-categories/${id}`,
DELETE: (id: string) => `company-categories/${id}`,
DETAILS: (id: string) => `company-categories/${id}`,
TOGGLE_PUBLISHED:(id:string) => `company-categories/${id}/publish`,
TOGGLE_PUBLISHED: (id: string) => `company-categories/${id}/publish`,
},
},
TENDERS: {
+17
View File
@@ -9,6 +9,7 @@ import {
ILogoutResponse,
LoginResponseSchema,
LogoutResponseSchema,
TChangeAdminStatusCredentials,
} from "../types";
export const userService = {
@@ -87,4 +88,20 @@ export const userService = {
throw error;
}
},
changeAdminStatus: async ({
id,
credentials,
}: {
id: string;
credentials: TChangeAdminStatusCredentials;
}) => {
try {
return (
await api.put(API_ENDPOINTS.USER.CHANGE_ADMIN_STATUS(id), credentials)
).data;
} catch (error) {
console.error("ERROR Caught in user service => change status", error);
throw error;
}
},
};
+6 -2
View File
@@ -58,13 +58,17 @@ export const LoginResponse = z.object({
export const LogoutResponse = z.object({
message: z.string(),
});
export const changeAdminStatusCredentialsSchema = z.object({
status: z.string(),
});
export type IUser = z.infer<typeof UserSchema>;
export type ILogoutResponse = z.infer<typeof LogoutResponse>;
export type ILoginCredentials = z.infer<typeof LoginCredentials>;
export type ILoginResponse = z.infer<typeof LoginResponse>;
export type IAdminListResponse = z.infer<typeof AdminListResponse>;
export type TChangeAdminStatusCredentials = z.infer<
typeof changeAdminStatusCredentialsSchema
>;
export const LogoutResponseSchema = createApiResponseSchema(LogoutResponse);
export const LoginResponseSchema = createApiResponseSchema(LoginResponse);
export const AdminListResponseSchema =
+7 -1
View File
@@ -6,7 +6,6 @@ export const msToDate = (ms: number) => {
return moment.default(ms).format("YYYY/MM/DD");
};
export const getStatusColor = <T>(status: T) => {
switch (status) {
case "active":
@@ -41,3 +40,10 @@ export const formatPhoneNumber = (input?: string) => {
return (hasPlus ? "+" : "") + groups.join(" ");
};
export const capitalize = (text: string) => {
return text
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ")
.trim();
};