product report list + detail report

This commit is contained in:
hamid zarghami
2025-09-29 16:04:57 +03:30
parent 25148282ae
commit 9ec483383e
16 changed files with 580 additions and 10 deletions
+6 -8
View File
@@ -26,11 +26,13 @@ const DefaulModal: FC<Props> = (props: Props) => {
{
props.open && (
<Fragment>
<div style={{ maxWidth: props.width }} className='xl:justify-center xl:items-center items-end flex overflow-x-hidden overflow-hidden fixed inset-0 z-[60] h-auto top-0 bottom-0 m-auto outline-none focus:outline-none xl:max-w-xl mx-auto'>
<div className='relative xl:h-full h-[80%] bottom-0 left-0 max-h-[80%] top-0 m-auto overflow-hidden flex xl:items-center sm:h-auto w-full xl:my-6 xl:p-2'>
<div className='border-0 h-auto p-5 lg:min-w-full overflow-y-auto rounded-3xl rounded-b-none xl:rounded-b-3xl relative flex flex-col w-full bg-white outline-none focus:outline-none'>
{/* Backdrop */}
<div onClick={props.close} className='fixed size-full top-0 bottom-0 right-0 bg-black/50 backdrop-blur-md inset-0 z-50'></div>
{/* Modal Container */}
<div className='fixed inset-0 z-[60] flex xl:justify-center xl:items-start items-end overflow-x-hidden overflow-y-auto'>
<div style={{ maxWidth: props.width }} className='relative xl:max-h-[90vh] xl:my-6 xl:max-w-xl mx-auto w-full xl:w-auto'>
<div className='border-0 xl:h-auto max-h-[80vh] p-5 lg:min-w-full overflow-y-auto rounded-3xl rounded-b-none xl:rounded-b-3xl relative flex flex-col w-full bg-white outline-none focus:outline-none'>
{
props.isHeader && props.title_header &&
<div className='pb-6 border-b border-white/20'>
@@ -40,13 +42,9 @@ const DefaulModal: FC<Props> = (props: Props) => {
}
{props.children}
</div>
</div>
</div>
<div onClick={props.close} className='fixed size-full top-0 bottom-0 right-0 bg-black/50 backdrop-blur-md inset-0 z-50 '></div>
</Fragment>
)
}
+5
View File
@@ -113,6 +113,10 @@ export const Pages = {
payment: {
list: "/payments/list",
},
report: {
product: "/reports/products",
category: "/reports/category",
},
support: {
create: "/support/create",
list: "/support/list",
@@ -164,5 +168,6 @@ export const Pages = {
financial: {
list: "/financial/transactions",
fines: "/financial/fines",
createFine: "/financial/create-fine",
},
};
+87
View File
@@ -0,0 +1,87 @@
import { useFormik } from 'formik'
import { type FC } from 'react'
import type { CreateFineType } from './types/Types'
import * as Yup from 'yup'
import { useCreateFine } from './hooks/usePaymentData'
import { useNavigate } from 'react-router-dom'
import Input from '../../components/Input'
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 CreateFine: FC = () => {
const navigate = useNavigate()
const createFineMutation = useCreateFine()
const formik = useFormik<CreateFineType>({
initialValues: {
fine_percentage: 0,
title: ''
},
validationSchema: Yup.object({
fine_percentage: Yup.number().required('درصد جریمه الزامی است').min(0, 'درصد جریمه نمی‌تواند منفی باشد'),
title: Yup.string().required('عنوان جریمه الزامی است'),
}),
onSubmit: (values) => {
createFineMutation.mutate(values, {
onSuccess: () => {
toast.success('قانون جریمه با موفقیت ایجاد شد')
navigate(Pages.financial.fines)
},
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={createFineMutation.isPending}
disabled={!formik.isValid || createFineMutation.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')}
error_text={formik.touched.title && formik.errors.title ? formik.errors.title : ''}
/>
<Input
label='درصد جریمه'
type='number'
placeholder='مثال: 5'
{...formik.getFieldProps('fine_percentage')}
error_text={formik.touched.fine_percentage && formik.errors.fine_percentage ? formik.errors.fine_percentage : ''}
/>
</div>
</div>
</div>
</div>
)
}
export default CreateFine
+10
View File
@@ -0,0 +1,10 @@
import { type FC } from 'react'
const Fines: FC = () => {
return (
<div>Fines</div>
)
}
export default Fines
+14 -1
View File
@@ -1,5 +1,5 @@
import * as api from "../service/PaymentService";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
export const useGetPayments = (page: number = 1) => {
return useQuery({
@@ -7,3 +7,16 @@ export const useGetPayments = (page: number = 1) => {
queryFn: () => api.getPayments(page),
});
};
export const useCreateFine = () => {
return useMutation({
mutationFn: api.createFine,
});
};
export const useGetFines = () => {
return useQuery({
queryKey: ["fines"],
queryFn: api.getFines,
});
};
+11 -1
View File
@@ -1,5 +1,5 @@
import axios from "../../../config/axios";
import type { PaymentResponse } from "../types/Types";
import type { CreateFineType, PaymentResponse } from "../types/Types";
export const getPayments = async (
page: number = 1
@@ -7,3 +7,13 @@ export const getPayments = async (
const { data } = await axios.get(`/admin/financial/payments?page=${page}`);
return data;
};
export const createFine = async (params: CreateFineType) => {
const { data } = await axios.post(`/admin/fine/rule`, params);
return data;
};
export const getFines = async () => {
const { data } = await axios.get(`/admin/fine/fines`);
return data;
};
+5
View File
@@ -49,3 +49,8 @@ export interface Pager {
prevPage: boolean | null;
nextPage: string | null;
}
export type CreateFineType = {
title: string;
fine_percentage: number;
};
+301
View File
@@ -0,0 +1,301 @@
import { type FC, useState } from 'react'
import { useGetProductReport } from './hooks/useReportData'
import { type Report } from './types/Types'
import PageLoading from '../../components/PageLoading'
import Error from '../../components/Error'
import Td from '../../components/Td'
import StatusWithText from '../../components/StatusWithText'
import DefaulModal from '../../components/DefaulModal'
import { Eye } from 'iconsax-react'
import Button from '@/components/Button'
const ProductReport: FC = () => {
const { data, isLoading, error } = useGetProductReport();
const [selectedReport, setSelectedReport] = useState<Report | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const openModal = (report: Report) => {
setSelectedReport(report);
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
setSelectedReport(null);
};
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 reports = data?.results?.reports || [];
const getStatusVariant = (status: string) => {
switch (status.toLowerCase()) {
case 'pending':
return 'warning';
case 'resolved':
return 'success';
case 'rejected':
return 'error';
default:
return 'warning';
}
};
const getStatusText = (status: string) => {
switch (status.toLowerCase()) {
case 'pending':
return 'در انتظار بررسی';
case 'resolved':
return 'بررسی شده';
case 'rejected':
return 'رد شده';
default:
return status;
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
return (
<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>
{reports.length === 0 ? (
<tr className='tr'>
<td colSpan={7} className="text-center py-8 text-gray-500">
هیچ گزارشی یافت نشد
</td>
</tr>
) : (
reports.map((report: Report) => (
<tr key={report._id} className='tr'>
<Td text="">
<div>
<div className="font-medium text-gray-900">
{report.user.fullName}
</div>
<div className="text-gray-500 text-xs">
{report.user.phoneNumber}
</div>
</div>
</Td>
<Td text="">
<div>
<div className="font-medium text-gray-900">
{report.product.title_fa}
</div>
<div className="text-gray-500 text-xs">
{report.product.title_en}
</div>
</div>
</Td>
<Td text="">
<div className="flex flex-wrap gap-1">
{report.reason.slice(0, 2).map((reason) => (
<span
key={reason._id}
className="inline-block bg-red-100 text-red-800 text-xs px-2 py-1 rounded-full"
>
{reason.title}
</span>
))}
{report.reason.length > 2 && (
<span className="inline-block bg-gray-100 text-gray-800 text-xs px-2 py-1 rounded-full">
+{report.reason.length - 2} دلیل دیگر
</span>
)}
</div>
</Td>
<Td text="">
<div className="max-w-xs truncate" title={report.description}>
{report.description}
</div>
</Td>
<Td text="">
<StatusWithText
variant={getStatusVariant(report.status)}
text={getStatusText(report.status)}
/>
</Td>
<Td text={formatDate(report.createdAt)} />
<Td text="">
<Button
onClick={() => openModal(report)}
className="h-8 text-xs w-fit"
variant="outline"
>
<Eye color='#000' size={16} className="ml-1" />
جزئیات
</Button>
</Td>
</tr>
))
)}
</tbody>
</table>
</div>
<DefaulModal
open={isModalOpen}
close={closeModal}
isHeader={true}
title_header="جزئیات گزارش محصول"
width={600}
>
{selectedReport && (
<div className="space-y-6">
{/* اطلاعات گزارش‌دهنده */}
<div className="bg-gray-50 p-4 rounded-xl">
<h3 className="text-lg font-semibold mb-3 text-gray-800">گزارشدهنده</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-600">نام کامل</label>
<p className="text-gray-900">{selectedReport.user.fullName}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-600">شماره تلفن</label>
<p className="text-gray-900">{selectedReport.user.phoneNumber}</p>
</div>
{selectedReport.user.nationalCode && (
<div>
<label className="block text-sm font-medium text-gray-600">کد ملی</label>
<p className="text-gray-900">{selectedReport.user.nationalCode}</p>
</div>
)}
{selectedReport.user.address && (
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-600">آدرس</label>
<p className="text-gray-900">{selectedReport.user.address}</p>
</div>
)}
</div>
</div>
{/* اطلاعات محصول */}
<div className="bg-blue-50 p-4 rounded-xl">
<h3 className="text-lg font-semibold mb-3 text-gray-800">محصول گزارششده</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-600">عنوان فارسی</label>
<p className="text-gray-900">{selectedReport.product.title_fa}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-600">عنوان انگلیسی</label>
<p className="text-gray-900">{selectedReport.product.title_en}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-600">مدل</label>
<p className="text-gray-900">{selectedReport.product.model}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-600">برند</label>
<p className="text-gray-900">{selectedReport.product.brand}</p>
</div>
{selectedReport.product.imagesUrl?.cover && (
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-600 mb-2">تصویر محصول</label>
<img
src={selectedReport.product.imagesUrl.cover}
alt={selectedReport.product.title_fa}
className="w-24 h-24 object-cover rounded-lg"
/>
</div>
)}
</div>
</div>
{/* دلایل گزارش */}
<div className="bg-red-50 p-4 rounded-xl">
<h3 className="text-lg font-semibold mb-3 text-gray-800">دلایل گزارش</h3>
<div className="flex flex-wrap gap-2">
{selectedReport.reason.map((reason) => (
<span
key={reason._id}
className="inline-block bg-red-100 text-red-800 text-sm px-3 py-1 rounded-full"
>
{reason.title}
</span>
))}
</div>
{selectedReport.reason.some(r => r.placeholder) && (
<div className="mt-3">
<label className="block text-sm font-medium text-gray-600 mb-2">توضیحات دلایل</label>
<div className="space-y-2">
{selectedReport.reason.filter(r => r.placeholder).map((reason) => (
<p key={reason._id} className="text-sm text-gray-700 bg-white p-2 rounded">
<strong>{reason.title}:</strong> {reason.placeholder}
</p>
))}
</div>
</div>
)}
</div>
{/* توضیحات گزارش */}
<div className="bg-yellow-50 p-4 rounded-xl">
<h3 className="text-lg font-semibold mb-3 text-gray-800">توضیحات گزارش</h3>
<p className="text-gray-900 whitespace-pre-wrap">{selectedReport.description}</p>
</div>
{/* وضعیت و تاریخ */}
<div className="bg-green-50 p-4 rounded-xl">
<h3 className="text-lg font-semibold mb-3 text-gray-800">اطلاعات گزارش</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-600">وضعیت</label>
<StatusWithText
variant={getStatusVariant(selectedReport.status)}
text={getStatusText(selectedReport.status)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-600">تاریخ گزارش</label>
<p className="text-gray-900">{formatDate(selectedReport.createdAt)}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-600">تاریخ آخرین بروزرسانی</label>
<p className="text-gray-900">{formatDate(selectedReport.updatedAt)}</p>
</div>
</div>
</div>
</div>
)}
</DefaulModal>
</div>
)
}
export default ProductReport
+9
View File
@@ -0,0 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import * as api from "../service/ReportService";
export const useGetProductReport = () => {
return useQuery({
queryKey: ["productReport"],
queryFn: api.getProductReport,
});
};
@@ -0,0 +1,7 @@
import axios from "../../../config/axios";
import type { ReportResponse } from "../types/Types";
export const getProductReport = async (): Promise<ReportResponse> => {
const { data } = await axios.get("/admin/products/user-reports");
return data;
};
+83
View File
@@ -0,0 +1,83 @@
export interface Reason {
_id: number;
title: string;
type: string;
placeholder: string | null;
createdAt: string;
updatedAt: string;
}
export interface User {
_id: string;
fullName: string;
phoneNumber: string;
dateOfBirth: string | null;
address: string | null;
nationalCode: string | null;
homeNumber: string | null;
createdAt: string;
updatedAt: string;
}
export interface ProductImagesUrl {
cover: string;
list: string[];
}
export interface ProductSpecification {
title: string;
values: string[];
}
export interface Product {
seoTitle: string | null;
seoDescription: string | null;
_id: number;
title_fa: string;
title_en: string;
model: string;
source: string;
description: string;
metaDescription: string;
tags: string[];
advantages: string[];
disAdvantages: string[];
totalRate: number;
category: string;
shop: string;
brand: string;
status: string;
adminComments: string | null;
step: number;
isFake: boolean;
variants: string[];
voice: string | null;
deleted: boolean;
specifications: ProductSpecification[];
createdAt: string;
updatedAt: string;
imagesUrl: ProductImagesUrl;
url: string;
rate?: number;
}
export interface Report {
_id: string;
user: User;
product: Product;
reason: Reason[];
description: string;
status: string;
createdAt: string;
updatedAt: string;
}
export interface ReportResults {
reports: Report[];
}
export interface ReportResponse {
status: number;
success: boolean;
results: ReportResults;
}
+12
View File
@@ -0,0 +1,12 @@
import { type FC } from 'react'
const TicketsList: FC = () => {
return (
<div>
Tickets List
</div>
)
}
export default TicketsList
+9
View File
@@ -0,0 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import * as api from "../service/TicketService";
export const useGetTickets = () => {
return useQuery({
queryKey: ["tickets"],
queryFn: api.getTickets,
});
};
@@ -0,0 +1,6 @@
import axios from "../../../config/axios";
export const getTickets = async () => {
const { data } = await axios.get(`/admin/tickets`);
return data;
};
+10
View File
@@ -39,6 +39,10 @@ import UpdateBrand from '@/pages/brand/Update'
import UpdateWarranty from '@/pages/warranty/Update'
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 ProductReport from '@/pages/report/ProductReport'
const MainRouter: FC = () => {
const { hasSubMenu } = useSharedStore()
@@ -99,6 +103,12 @@ const MainRouter: FC = () => {
<Route path={Pages.admin.create} element={<AdminCreate />} />
<Route path={Pages.financial.list} element={<PaymentList />} />
<Route path={Pages.financial.createFine} element={<CreateFine />} />
<Route path={Pages.financial.fines} element={<Fines />} />
<Route path={Pages.ticket.list} element={<TicketsList />} />
<Route path={Pages.report.product} element={<ProductReport />} />
</Routes>
</div>
</div>
+5
View File
@@ -667,6 +667,11 @@ const ReportsSubMenu: FC = () => {
isActive={isActive('products')}
link="/reports/products"
/>
<SubMenuItem
title={'دسته بندی گزارشات'}
isActive={isActive('category')}
link={Pages.report.category}
/>
</div>
</div>
)