Complete TicketsList page like other list pages

- Add pagination to useGetTickets hook
- Add pagination parameters to getTickets service
- Add Pager interface and update ITicketsResponse
- Complete TicketsList component with table, status, pagination and navigation
This commit is contained in:
hamid zarghami
2025-10-12 16:56:14 +03:30
parent 7859388439
commit baa57bc7d5
11 changed files with 668 additions and 4 deletions
+5 -1
View File
@@ -32,7 +32,11 @@ export const Pages = {
list: "/tickets/list",
create: "/tickets/create",
detail: "/tickets/messages/",
category: "/tickets/category",
category: {
list: "/tickets/category",
create: "/tickets/category/create",
update: "/tickets/category/update/",
},
},
announcement: {
list: "/announcement",
+85
View File
@@ -0,0 +1,85 @@
import { type FC } from 'react'
import { useGetTicketCategories } from './hooks/useTicketData'
import { type ITicketCategory } from './types/Types'
import { Pages } from '../../config/Pages'
import Button from '../../components/Button'
import Td from '../../components/Td'
import { useNavigate } from 'react-router-dom'
import PageLoading from '../../components/PageLoading'
const Category: FC = () => {
const navigate = useNavigate()
const { data, isLoading } = useGetTicketCategories()
// const deleteCategoryMutation = useDeleteTicketCategory()
// const handleDeleteCategory = async (categoryId: string) => {
// await deleteCategoryMutation.mutateAsync(categoryId)
// refetch()
// }
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<PageLoading />
</div>
)
}
const categories = data?.results?.categories || []
return (
<div>
<div className='flex justify-end mt-5'>
<Button
label='افزودن دسته‌بندی'
onClick={() => navigate(Pages.ticket.category.create)}
className='w-fit'
/>
</div>
<div className='relative overflow-x-auto rounded-3xl mt-5 w-full'>
<table className='w-full text-sm'>
<thead className='thead'>
<tr>
<Td text={'عنوان'} />
<Td text={'توضیحات'} />
<Td text={'تاریخ ایجاد'} />
<Td text={'تاریخ بروزرسانی'} />
{/* <Td text={'عملیات'} /> */}
</tr>
</thead>
<tbody>
{categories.length === 0 ? (
<tr className='tr'>
<td colSpan={5} className="text-center py-8 text-gray-500">
هیچ دستهبندی یافت نشد
</td>
</tr>
) : (
categories.map((category: ITicketCategory) => (
<tr key={category._id} className='tr'>
<Td text={category.title_fa} />
<Td text={category.description} />
<Td text={category.createdAt} />
<Td text={category.updatedAt} />
{/* <Td text="">
<div className="flex items-center gap-2">
<Link to={`${Pages.ticket.category.update}${category._id}`}>
<Edit color='#8C90A3' size={16} className="cursor-pointer hover:text-blue-500" />
</Link>
<TrashWithConfrim
onDelete={() => handleDeleteCategory(category._id)}
isLoading={deleteCategoryMutation.isPending}
/>
</div>
</Td> */}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
export default Category
+88
View File
@@ -0,0 +1,88 @@
import { useFormik } from 'formik'
import { type FC } from 'react'
import { useCreateTicketCategory } from './hooks/useTicketData'
import type { CreateTicketCategoryType } from './types/Types'
import * as Yup from 'yup'
import { useNavigate } from 'react-router-dom'
import Input from '../../components/Input'
import Textarea from '../../components/Textarea'
import Button from '../../components/Button'
import { TickCircle } from 'iconsax-react'
import { toast } from 'react-toastify'
import { extractErrorMessage } from '@/helpers/utils'
import { Pages } from '../../config/Pages'
const Create: FC = () => {
const navigate = useNavigate()
const createCategoryMutation = useCreateTicketCategory()
const formik = useFormik<CreateTicketCategoryType>({
initialValues: {
title_fa: "",
description: "",
},
validationSchema: Yup.object({
title_fa: Yup.string().required("عنوان الزامی است"),
description: Yup.string().required("توضیحات الزامی است"),
}),
onSubmit: async (values) => {
createCategoryMutation.mutate(values, {
onSuccess: () => {
toast.success('دسته‌بندی با موفقیت ایجاد شد')
navigate(Pages.ticket.category.list)
},
onError: (error) => {
toast.error(extractErrorMessage(error))
}
})
}
})
return (
<div className='mt-4'>
<div className='flex justify-between items-center'>
<div className='flex items-center gap-3'>
<h1>ساخت دستهبندی جدید</h1>
</div>
<div>
<Button
className='px-6'
onClick={() => formik.handleSubmit()}
isLoading={createCategoryMutation.isPending}
disabled={!formik.isValid || createCategoryMutation.isPending}
>
<div className='flex gap-2 items-center'>
<TickCircle size={16} color='#fff' />
<div>ثبت دستهبندی</div>
</div>
</Button>
</div>
</div>
<div className='flex gap-6 xl:mt-8 mt-6'>
{/* اطلاعات اصلی */}
<div className='flex-1 text-sm bg-white py-8 xl:px-10 px-6 rounded-3xl'>
<h2 className='text-lg font-medium mb-6'>اطلاعات دستهبندی</h2>
<div className='space-y-6'>
<Input
label='عنوان دسته‌بندی'
placeholder='مثال: پشتیبانی فنی'
{...formik.getFieldProps('title_fa')}
error_text={formik.touched.title_fa && formik.errors.title_fa ? formik.errors.title_fa : ''}
/>
<Textarea
label='توضیحات'
placeholder='توضیحات دسته‌بندی را وارد کنید'
{...formik.getFieldProps('description')}
error_text={formik.touched.description && formik.errors.description ? formik.errors.description : ''}
/>
</div>
</div>
</div>
</div>
)
}
export default Create
+151
View File
@@ -0,0 +1,151 @@
import { type FC, useState } from 'react'
import { useGetTickets } from './hooks/useTicketData'
import { type ITicket, type TicketStatus } from './types/Types'
import PageLoading from '../../components/PageLoading'
import Error from '../../components/Error'
import PaginationUi from '../../components/PaginationUi'
import Td from '../../components/Td'
import { Calendar, MessageQuestion, User as UserIcon, Category } from 'iconsax-react'
import Button from '@/components/Button'
import { useNavigate } from 'react-router-dom'
import { Pages } from '@/config/Pages'
import StatusWithText from '../../components/StatusWithText'
const TicketsList: FC = () => {
const navigate = useNavigate();
const [currentPage, setCurrentPage] = useState(1);
const { data: ticketsData, isLoading, error } = useGetTickets(currentPage);
if (isLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<PageLoading />
</div>
);
}
if (error) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<Error errorText="خطا در بارگذاری تیکت‌ها" />
</div>
);
}
const tickets = ticketsData?.results?.tickets || [];
const pager = ticketsData?.results?.pager;
const formatDate = (dateString: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('fa-IR');
};
const getStatusVariant = (status: TicketStatus) => {
switch (status) {
case 'closed': return 'error';
case 'answered': return 'success';
case 'pending': return 'warning';
default: return 'warning';
}
};
const getStatusText = (status: TicketStatus) => {
switch (status) {
case 'closed': return 'بسته شده';
case 'answered': return 'پاسخ داده شده';
case 'pending': return 'در انتظار';
default: return status;
}
};
return (
<div>
<div className='flex justify-end mt-5'>
<Button
label='افزودن تیکت'
onClick={() => navigate(`${Pages.ticket.create}`)}
className='w-fit'
/>
</div>
<div className='relative overflow-x-auto rounded-3xl mt-5 w-full'>
<table className='w-full text-sm'>
<thead className='thead'>
<tr>
<Td text={'شماره تیکت'} />
<Td text={'فروشنده'} />
<Td text={'موضوع'} />
<Td text={'دسته‌بندی'} />
<Td text={'وضعیت'} />
<Td text={'تاریخ بروزرسانی'} />
<Td text={'عملیات'} />
</tr>
</thead>
<tbody>
{tickets.length === 0 ? (
<tr className='tr'>
<td colSpan={7} className="text-center py-8 text-gray-500">
هیچ تیکتی یافت نشد
</td>
</tr>
) : (
tickets.map((ticket: ITicket) => (
<tr key={ticket._id} className='tr'>
<Td text="">
<div className="flex items-center gap-2">
<MessageQuestion size={16} color="#8C90A3" />
<span>{ticket.numericId}</span>
</div>
</Td>
<Td text="">
<div className="flex items-center gap-2">
<UserIcon size={16} color="#8C90A3" />
<span>{ticket.seller?.fullName || '-'}</span>
</div>
</Td>
<Td text={ticket.subject} />
<Td text="">
<div className="flex items-center gap-2">
<Category size={16} color="#8C90A3" />
<span>{ticket.category?.title_fa || '-'}</span>
</div>
</Td>
<Td text="">
<StatusWithText
variant={getStatusVariant(ticket.status)}
text={getStatusText(ticket.status)}
/>
</Td>
<Td text="">
<div className="flex items-center gap-2">
<Calendar size={16} color="#8C90A3" />
<span>{formatDate(ticket.updatedAt)}</span>
</div>
</Td>
<Td text="">
<div className="flex items-center gap-2">
<Button
label='مشاهده'
onClick={() => navigate(`${Pages.ticket.detail}${ticket._id}`)}
className='h-8 text-xs'
/>
</div>
</Td>
</tr>
))
)}
</tbody>
</table>
</div>
<PaginationUi
pager={pager}
onPageChange={(page) => {
setCurrentPage(page);
}}
/>
</div>
)
}
export default TicketsList
+109
View File
@@ -0,0 +1,109 @@
import { useFormik } from 'formik'
import { type FC, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { useGetTicketCategory, useUpdateTicketCategory } from './hooks/useTicketData'
import type { CreateTicketCategoryType } from './types/Types'
import * as Yup from 'yup'
import { useNavigate } from 'react-router-dom'
import Input from '../../components/Input'
import Textarea from '../../components/Textarea'
import Button from '../../components/Button'
import { TickCircle } from 'iconsax-react'
import { toast } from 'react-toastify'
import { extractErrorMessage } from '@/helpers/utils'
import { Pages } from '../../config/Pages'
const Update: FC = () => {
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const { data: categoryDetail, isLoading: categoryDetailLoading, error: categoryDetailError } = useGetTicketCategory(id!)
const updateCategoryMutation = useUpdateTicketCategory()
const formik = useFormik<CreateTicketCategoryType>({
initialValues: {
title_fa: "",
description: "",
},
validationSchema: Yup.object({
title_fa: Yup.string().required("عنوان الزامی است"),
description: Yup.string().required("توضیحات الزامی است"),
}),
onSubmit: async (values) => {
updateCategoryMutation.mutate({ categoryId: id!, params: values }, {
onSuccess: () => {
toast.success('دسته‌بندی با موفقیت ویرایش شد')
navigate(Pages.ticket.category.list)
},
onError: (error) => {
toast.error(extractErrorMessage(error))
}
})
}
})
useEffect(() => {
if (categoryDetail?.results?.category) {
formik.setValues({
title_fa: categoryDetail.results.category.title_fa,
description: categoryDetail.results.category.description,
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categoryDetail])
if (categoryDetailLoading) {
return <div>در حال بارگذاری...</div>
}
if (categoryDetailError) {
return <div>خطا در بارگذاری دادهها</div>
}
return (
<div className='mt-4'>
<div className='flex justify-between items-center'>
<div className='flex items-center gap-3'>
<h1>ویرایش دستهبندی</h1>
</div>
<div>
<Button
className='px-6'
onClick={() => formik.handleSubmit()}
isLoading={updateCategoryMutation.isPending}
disabled={!formik.isValid || updateCategoryMutation.isPending}
>
<div className='flex gap-2 items-center'>
<TickCircle size={16} color='#fff' />
<div>ذخیره تغییرات</div>
</div>
</Button>
</div>
</div>
<div className='flex gap-6 xl:mt-8 mt-6'>
{/* اطلاعات اصلی */}
<div className='flex-1 text-sm bg-white py-8 xl:px-10 px-6 rounded-3xl'>
<h2 className='text-lg font-medium mb-6'>اطلاعات دستهبندی</h2>
<div className='space-y-6'>
<Input
label='عنوان دسته‌بندی'
placeholder='مثال: پشتیبانی فنی'
{...formik.getFieldProps('title_fa')}
error_text={formik.touched.title_fa && formik.errors.title_fa ? formik.errors.title_fa : ''}
/>
<Textarea
label='توضیحات'
placeholder='توضیحات دسته‌بندی را وارد کنید'
{...formik.getFieldProps('description')}
error_text={formik.touched.description && formik.errors.description ? formik.errors.description : ''}
/>
</div>
</div>
</div>
</div>
)
}
export default Update
+78
View File
@@ -0,0 +1,78 @@
import * as api from "../service/TicketService";
import type {
CreateTicketCategoryType,
CreateTicketMessageType,
} from "../types/Types";
import { useMutation, useQuery } from "@tanstack/react-query";
export const useGetTickets = (page: number = 1, limit: number = 10) => {
return useQuery({
queryKey: ["tickets", page, limit],
queryFn: () => api.getTickets(page, limit),
});
};
export const useGetTicketCategories = () => {
return useQuery({
queryKey: ["ticket-categories"],
queryFn: api.getTicketCategories,
});
};
export const useGetTicketCategory = (categoryId: string) => {
return useQuery({
queryKey: ["ticket-category", categoryId],
queryFn: () => api.getTicketCategory(categoryId),
enabled: !!categoryId,
});
};
export const useGetTicketMessages = (ticketId: string) => {
return useQuery({
queryKey: ["ticket-messages", ticketId],
queryFn: () => api.getTicketMessages(ticketId),
});
};
export const useAddTicketMessage = () => {
return useMutation({
mutationFn: ({
ticketId,
params,
}: {
ticketId: string;
params: CreateTicketMessageType;
}) => api.addTicketMessage(ticketId, params),
});
};
export const useGetTicket = (ticketId: string) => {
return useQuery({
queryKey: ["ticket", ticketId],
queryFn: () => api.getTicket(ticketId),
});
};
export const useCreateTicketCategory = () => {
return useMutation({
mutationFn: api.createTicketCategory,
});
};
export const useUpdateTicketCategory = () => {
return useMutation({
mutationFn: ({
categoryId,
params,
}: {
categoryId: string;
params: CreateTicketCategoryType;
}) => api.updateTicketCategory(categoryId, params),
});
};
export const useDeleteTicketCategory = () => {
return useMutation({
mutationFn: api.deleteTicketCategory,
});
};
+74
View File
@@ -0,0 +1,74 @@
import axios from "@/config/axios";
import type {
CreateTicketCategoryType,
CreateTicketMessageType,
ITicketCategoriesResponse,
ITicketsResponse,
ITicketResponse,
} from "../types/Types";
export const getTickets = async (
page: number = 1,
limit: number = 10
): Promise<ITicketsResponse> => {
const { data } = await axios.get("/admin/tickets", {
params: { page, limit },
});
return data;
};
export const addTicketMessage = async (
ticketId: string,
params: CreateTicketMessageType
) => {
const { data } = await axios.post(
`/admin/tickets/${ticketId}/add-message`,
params
);
return data;
};
export const getTicket = async (ticketId: string): Promise<ITicketResponse> => {
const { data } = await axios.get(`/admin/tickets/${ticketId}`);
return data;
};
export const getTicketCategories = async (): Promise<
ITicketCategoriesResponse
> => {
const { data } = await axios.get("/tickets/categories");
return data;
};
export const getTicketCategory = async (categoryId: string) => {
const { data } = await axios.get(`/admin/tickets/category/${categoryId}`);
return data;
};
export const createTicketCategory = async (
params: CreateTicketCategoryType
) => {
const { data } = await axios.post("/admin/tickets/category", params);
return data;
};
export const getTicketMessages = async (ticketId: string) => {
const { data } = await axios.get(`/ticket-messages/${ticketId}`);
return data;
};
export const updateTicketCategory = async (
categoryId: string,
params: CreateTicketCategoryType
) => {
const { data } = await axios.put(
`/admin/tickets/category/${categoryId}`,
params
);
return data;
};
export const deleteTicketCategory = async (categoryId: string) => {
const { data } = await axios.delete(`/admin/tickets/category/${categoryId}`);
return data;
};
+64
View File
@@ -0,0 +1,64 @@
import type { IBaseResponse } from "@/types/response.types";
export interface Pager {
page: number;
limit: number;
totalItems: number;
totalPages: number;
prevPage: boolean;
nextPage: string | null;
}
export interface ITicketCategory {
_id: string;
title_fa: string;
description: string;
createdAt: string;
updatedAt: string;
}
export interface ITicketCategoriesResponse extends IBaseResponse {
results: {
categories: ITicketCategory[];
};
}
export type CreateTicketCategoryType = {
title_fa: string;
description: string;
};
export type CreateTicketMessageType = {
content: "مشکل در اضافه کردن محصول";
attachments: string[];
};
export interface ISeller {
_id: string;
fullName: string;
phoneNumber: string;
email: string;
}
export type TicketStatus = "closed" | "answered" | "pending";
export interface ITicket {
_id: string;
numericId: number;
seller: ISeller;
category: ITicketCategory;
subject: string;
status: TicketStatus;
updatedAt: string;
}
export interface ITicketsResponse extends IBaseResponse {
results: {
tickets: ITicket[];
pager: Pager;
};
}
export interface ITicketResponse extends IBaseResponse {
results: ITicket;
}
+7 -1
View File
@@ -41,7 +41,7 @@ import PaymentList from '@/pages/payment/List'
import UpdateShipment from '@/pages/shipment/Update'
import CreateFine from '@/pages/payment/CreateFine'
import Fines from '@/pages/payment/Fines'
import TicketsList from '@/pages/tickets/List'
import TicketsList from '@/pages/ticket/TicketsList'
import ProductReport from '@/pages/report/ProductReport'
import CategoryReport from '@/pages/report/Category'
import AboutUsList from '@/pages/setting/AboutUsList'
@@ -72,6 +72,9 @@ import Learning from '@/pages/seller/Learning'
import CreateLearning from '@/pages/seller/CreateLearning'
import UpdateLearning from '@/pages/seller/UpdateLearning'
import ContractPage from '@/pages/seller/Contract'
import Category from '@/pages/ticket/Category'
import CreateCategory from '@/pages/ticket/Create'
import UpdateCategory from '@/pages/ticket/Update'
const MainRouter: FC = () => {
const { hasSubMenu } = useSharedStore()
@@ -136,6 +139,9 @@ const MainRouter: FC = () => {
<Route path={Pages.financial.fines} element={<Fines />} />
<Route path={Pages.ticket.list} element={<TicketsList />} />
<Route path={Pages.ticket.category.list} element={<Category />} />
<Route path={Pages.ticket.category.create} element={<CreateCategory />} />
<Route path={`${Pages.ticket.category.update}:id`} element={<UpdateCategory />} />
<Route path={Pages.report.product} element={<ProductReport />} />
<Route path={Pages.report.category} element={<CategoryReport />} />
+6 -1
View File
@@ -633,7 +633,12 @@ const TicketsSubMenu: FC = () => {
<SubMenuItem
title={'لیست تیکت ها'}
isActive={isActive('list')}
link="/tickets/list"
link={Pages.ticket.list}
/>
<SubMenuItem
title={'دسته بندی تیکت ها'}
isActive={isActive('category')}
link={Pages.ticket.category.list}
/>
</div>
</div>
+1 -1
View File
@@ -42,7 +42,7 @@ const TicketSubMenu: FC = () => {
<SubMenuItem
title={t('submenu.category')}
isActive={isActive('category')}
link={Pages.ticket.category}
link={Pages.ticket.category.list}
/>
</div>
</div>