request + detail + change status request

This commit is contained in:
hamid zarghami
2025-05-21 14:42:17 +03:30
parent 840901c02c
commit dc05ebccc3
12 changed files with 654 additions and 21 deletions
+1
View File
@@ -3,6 +3,7 @@ VITE_REFRESH_TOKEN_NAME = 'dsc_refresh_token'
# VITE_BASE_URL = 'http://192.168.1.113:4001'
VITE_BASE_URL = 'https://api-dzone.danakcorp.com'
# VITE_DANAK_BASE_URL ='http://192.168.1.113:4000'
VITE_DANAK_BASE_URL ='https://api.danakcorp.com'
VITE_SERVICE_ID = '7e3c2f08-b7e7-4402-ae5f-fea99cff56bd'
VITE_WORKSPACE_ID = 'workspace_id'
VITE_LOGIN_URL = 'https://console.danakcorp.com/auth/login'
+1
View File
@@ -6,6 +6,7 @@ export const Pages = {
},
requests: {
list: "/requests/list",
detail: "/requests/detail/",
},
dashboard: "/dashboard",
services: {
+61
View File
@@ -843,5 +843,66 @@
"select_company": "انتخاب شرکت",
"register_date": "تاریخ ثبت نام",
"company": "شرکت"
},
"request": {
"request_list": "لیست درخواست ها",
"details": "جزئیات درخواست",
"basic_info": "اطلاعات پایه",
"company_info": "اطلاعات شرکت",
"user_info": "اطلاعات کاربر",
"additional_info": "اطلاعات تکمیلی",
"id": "شناسه",
"created_at": "تاریخ ایجاد",
"updated_at": "تاریخ بروزرسانی",
"status": "وضعیت",
"reject_reason": "دلیل رد",
"company_name": "نام شرکت",
"ceo": "مدیرعامل",
"email": "ایمیل",
"phone": "شماره تماس",
"address": "آدرس",
"identification_number": "شماره شناسایی",
"date_of_establishment": "تاریخ تاسیس",
"description": "توضیحات",
"company_status": "وضعیت شرکت",
"business": "کسب و کار",
"invoice_count": "تعداد فاکتور",
"website": "وبسایت",
"map_location": "موقعیت مکانی",
"view_on_map": "مشاهده روی نقشه",
"profile_image": "تصویر پروفایل",
"cover_image": "تصویر کاور",
"view_image": "مشاهده تصویر",
"products": "محصولات",
"services": "خدمات",
"actions": "عملیات",
"approve": "تایید",
"reject": "رد",
"approving": "در حال تایید",
"rejecting": "در حال رد",
"enter_reject_reason": "دلیل رد درخواست را وارد کنید",
"approve_success": "درخواست با موفقیت تایید شد",
"reject_success": "درخواست با موفقیت رد شد",
"approve_error": "خطا در تایید درخواست",
"reject_error": "خطا در رد درخواست",
"pending": "در انتظار بررسی",
"approved": "تایید شده",
"rejected": "رد شده",
"industry": "صنعت",
"full_name": "نام و نام خانوادگی",
"national_code": "کد ملی",
"role": "نقش",
"customer": "مشتری",
"date_receipt": "تاریخ درخواست",
"last_date_receipt": "آخرین تاریخ درخواست",
"total": "مبلغ کل",
"lateFee": "جریمه دیرکرد",
"service": "سرویس",
"status_paid": "وضعیت پرداخت",
"recurring": "تکرار شونده",
"maxRecurringCycles": "حداکثر تعداد تکرار",
"paid": "پرداخت شده",
"recurringPeriod": "دوره تکرار",
"user": "کاربر"
}
}
+362
View File
@@ -0,0 +1,362 @@
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import moment from 'moment-jalaali'
import PageLoading from '../../components/PageLoading'
import { useChangeRequestStatus, useGetRequestDetail } from './hooks/useRequestData'
import ModalConfrim from '../../components/ModalConfrim'
import { toast } from 'react-toastify'
import { ErrorType } from '../../helpers/types'
interface Product {
id: string
title: string
imageUrl: string
}
interface Service {
id: string
title: string
imageUrl: string
}
interface Business {
id: string
name: string
slug: string
}
interface Company {
id: string
name: string
chiefExecutiveOfficer: string
email: string
phone: string
identificationNumber: string
dateOfEstablishment: string
address: string
mapAddressLink: string
description: string
websiteUrl: string
profileImageUrl: string
coverImageUrl: string
status: string
industry: string
business: string
products: Product[]
services: Service[]
}
interface Request {
id: string
createdAt: string
updatedAt: string
rejectReason: string | null
requestStatus: string
company: Company
business: Business
}
const RequestDetail: FC = () => {
const { t } = useTranslation('global')
const { id } = useParams()
const { data: response, isLoading } = useGetRequestDetail(id || '')
const [rejectReason, setRejectReason] = useState('')
const [showRejectModal, setShowRejectModal] = useState(false)
const { mutate: changeRequestStatus, isPending } = useChangeRequestStatus()
if (isLoading) {
return (
<div className='mt-5'>
<PageLoading />
</div>
)
}
if (!response?.data?.request) {
return <div>Request not found</div>
}
const request: Request = response.data.request
const handleApprove = () => {
if (id) {
changeRequestStatus({
id,
status: 'APPROVED',
rejectReason: ''
})
}
}
const handleReject = (rejectReason: string) => {
if (id) {
changeRequestStatus({
id,
status: 'REJECTED',
rejectReason: rejectReason
}, {
onSuccess: () => {
toast.success(t('success'))
window.location.reload()
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error?.message[0])
}
})
}
}
return (
<div className='mt-4'>
<div className='flex items-center gap-4 mb-6'>
<h1 className='text-lg font-bold'>{t('request.details')}</h1>
</div>
<div className='bg-white rounded-lg shadow p-6'>
<div className='grid grid-cols-2 gap-6'>
<div className='space-y-6'>
<div className='bg-gray-50 p-4 rounded-lg'>
<h2 className='text-base font-semibold mb-4'>{t('request.basic_info')}</h2>
<div className='space-y-3'>
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.id')}:</span>
<span className='text-sm font-medium'>{request.id}</span>
</div>
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.created_at')}:</span>
<span className='text-sm font-medium'>{moment(request.createdAt).format('jYYYY/jMM/jDD HH:mm')}</span>
</div>
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.updated_at')}:</span>
<span className='text-sm font-medium'>{moment(request.updatedAt).format('jYYYY/jMM/jDD HH:mm')}</span>
</div>
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.status')}:</span>
<span className='text-sm font-medium'>{request.requestStatus ? t(`request.${request.requestStatus.toLowerCase()}`) : '-'}</span>
</div>
{request.rejectReason && (
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.reject_reason')}:</span>
<span className='text-sm font-medium'>{request.rejectReason}</span>
</div>
)}
</div>
</div>
<div className='bg-gray-50 p-4 rounded-lg'>
<h2 className='text-base font-semibold mb-4'>{t('request.company_info')}</h2>
<div className='space-y-3'>
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.company_name')}:</span>
<span className='text-sm font-medium'>{request.company.name || '-'}</span>
</div>
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.ceo')}:</span>
<span className='text-sm font-medium'>{request.company.chiefExecutiveOfficer || '-'}</span>
</div>
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.email')}:</span>
<span className='text-sm font-medium'>{request.company.email || '-'}</span>
</div>
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.phone')}:</span>
<span className='text-sm font-medium'>{request.company.phone || '-'}</span>
</div>
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.address')}:</span>
<span className='text-sm font-medium'>{request.company.address || '-'}</span>
</div>
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.identification_number')}:</span>
<span className='text-sm font-medium'>{request.company.identificationNumber || '-'}</span>
</div>
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.date_of_establishment')}:</span>
<span className='text-sm font-medium'>{request.company.dateOfEstablishment ? moment(request.company.dateOfEstablishment).format('jYYYY/jMM/jDD') : '-'}</span>
</div>
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.description')}:</span>
<span className='text-sm font-medium'>{request.company.description || '-'}</span>
</div>
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.company_status')}:</span>
<span className='text-sm font-medium'>{request.company.status ? t(`request.${request.company.status.toLowerCase()}`) : '-'}</span>
</div>
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.business')}:</span>
<span className='text-sm font-medium'>{request.business.name || '-'}</span>
</div>
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.invoice_count')}:</span>
<span className='text-sm font-medium'>0</span>
</div>
</div>
</div>
</div>
<div className='space-y-6'>
<div className='bg-gray-50 p-4 rounded-lg'>
<h2 className='text-base font-semibold mb-4'>{t('request.additional_info')}</h2>
<div className='space-y-3'>
{request.company.websiteUrl && (
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.website')}:</span>
<a
href={request.company.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className='text-sm text-blue-600 hover:underline'
>
{request.company.websiteUrl}
</a>
</div>
)}
{request.company.mapAddressLink && (
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.map_location')}:</span>
<a
href={request.company.mapAddressLink}
target="_blank"
rel="noopener noreferrer"
className='text-sm text-blue-600 hover:underline'
>
{t('request.view_on_map')}
</a>
</div>
)}
{request.company.profileImageUrl && (
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.profile_image')}:</span>
<a
href={request.company.profileImageUrl}
target="_blank"
rel="noopener noreferrer"
className='text-sm text-blue-600 hover:underline'
>
{t('request.view_image')}
</a>
</div>
)}
{request.company.coverImageUrl && (
<div className='flex justify-between'>
<span className='text-sm text-gray-600'>{t('request.cover_image')}:</span>
<a
href={request.company.coverImageUrl}
target="_blank"
rel="noopener noreferrer"
className='text-sm text-blue-600 hover:underline'
>
{t('request.view_image')}
</a>
</div>
)}
</div>
</div>
{request.company.products && request.company.products.length > 0 && (
<div className='bg-gray-50 p-4 rounded-lg'>
<h2 className='text-base font-semibold mb-4'>{t('request.products')}</h2>
<div className='grid grid-cols-2 gap-4'>
{request.company.products.map((product: Product) => (
<div key={product.id} className='bg-white p-3 rounded-lg shadow-sm'>
<div className='aspect-video mb-2 overflow-hidden rounded-lg'>
<img
src={product.imageUrl}
alt={product.title}
className='w-full h-full object-cover'
/>
</div>
<h3 className='text-sm font-medium'>{product.title}</h3>
</div>
))}
</div>
</div>
)}
{request.company.services && request.company.services.length > 0 && (
<div className='bg-gray-50 p-4 rounded-lg'>
<h2 className='text-base font-semibold mb-4'>{t('request.services')}</h2>
<div className='grid grid-cols-2 gap-4'>
{request.company.services.map((service: Service) => (
<div key={service.id} className='bg-white p-3 rounded-lg shadow-sm'>
<div className='aspect-video mb-2 overflow-hidden rounded-lg'>
<img
src={service.imageUrl}
alt={service.title}
className='w-full h-full object-cover'
/>
</div>
<h3 className='text-sm font-medium'>{service.title}</h3>
</div>
))}
</div>
</div>
)}
{request.requestStatus === 'PENDING' && (
<div className='bg-gray-50 p-4 rounded-lg'>
<h2 className='text-base font-semibold mb-4'>{t('request.actions')}</h2>
<div className='flex gap-4'>
<button
onClick={handleApprove}
disabled={isPending}
className='flex-1 bg-green-500 text-white py-2 px-4 rounded-lg hover:bg-green-600 transition-colors disabled:opacity-50 text-sm'
>
{isPending ? t('request.approving') : t('request.approve')}
</button>
<button
onClick={() => setShowRejectModal(true)}
disabled={isPending}
className='flex-1 bg-red-500 text-white py-2 px-4 rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50 text-sm'
>
{isPending ? t('request.rejecting') : t('request.reject')}
</button>
</div>
</div>
)}
</div>
</div>
</div>
<ModalConfrim
isHasDescription
isOpen={showRejectModal}
close={() => setShowRejectModal(false)}
label={t('request.reject_reason')}
onConfrim={(text) => handleReject(text || '')}
/>
{showRejectModal && (
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center'>
<div className='bg-white p-6 rounded-lg w-96'>
<h3 className='text-base font-semibold mb-4'>{t('request.reject_reason')}</h3>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
className='w-full p-2 border rounded-lg mb-4 text-sm'
rows={4}
placeholder={t('request.enter_reject_reason')}
/>
<div className='flex gap-4'>
<button
onClick={() => setShowRejectModal(false)}
className='flex-1 bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition-colors text-sm'
>
{t('cancel')}
</button>
<button
onClick={() => handleReject(rejectReason)}
disabled={!rejectReason || isPending}
className='flex-1 bg-red-500 text-white py-2 px-4 rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50 text-sm'
>
{isPending ? t('request.rejecting') : t('request.reject')}
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default RequestDetail
+88
View File
@@ -0,0 +1,88 @@
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useGetRequests } from './hooks/useRequestData'
import Pagination from '../../components/Pagination'
import Td from '../../components/Td'
import PageLoading from '../../components/PageLoading'
import { RequestItemType } from './types/Types'
import moment from 'moment-jalaali'
import { useNavigate } from 'react-router-dom'
import { Eye } from 'iconsax-react'
import { Pages } from '../../config/Pages'
const RequestsList: FC = () => {
const { t } = useTranslation('global')
const [page, setPage] = useState(1)
const { data, isLoading } = useGetRequests(page)
const navigate = useNavigate()
const handleRowClick = (id: string) => {
navigate(Pages.requests.detail + id)
}
return (
<div className='mt-4'>
<div>
{t('request.request_list')}
</div>
{
isLoading ?
<div className='mt-5'>
<PageLoading />
</div>
:
<div className='relative overflow-x-auto rounded-3xl mt-9 w-full'>
<table className='w-full text-sm'>
<thead className='thead'>
<tr>
<Td text={t('ticket.number')} />
<Td text={t('request.user')} />
<Td text={t('request.date_receipt')} />
<Td text={t('request.status')} />
<Td text={t('request.business')} />
<Td text={t('request.user_info')} />
<Td text={t('detail')} />
</tr>
</thead>
<tbody>
{
data?.data?.requests?.map((item: RequestItemType, index: number) => {
return (
<tr
key={item.id}
className='tr hover:bg-gray-50'
>
<Td text={String(index + 1)} />
<Td text={`${item.user.firstName} ${item.user.lastName}`} />
<Td text={moment(item.createdAt).format('jYYYY/jMM/jDD')} />
<Td text={t(`request.${item.requestStatus.toLowerCase()}`)} />
<Td text={item.company?.name} />
<Td text={item.company.name} />
<Td text=''>
<Eye
size={20}
color='#888'
onClick={() => handleRowClick(item.id)}
/>
</Td>
</tr>
)
})
}
</tbody>
</table>
<Pagination
totalPages={data?.data?.pager?.totalPages}
currentPage={page}
onPageChange={setPage}
/>
</div>
}
</div>
)
}
export default RequestsList
@@ -0,0 +1,47 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import {
approveRequest,
getRequestDetail,
getRequests,
rejectRequest,
} from "../service/RequestService";
export const useGetRequests = (page: number = 1) => {
return useQuery({
queryKey: ["requests", page],
queryFn: () => getRequests(page),
});
};
export const useGetRequestDetail = (id: string) => {
return useQuery({
queryKey: ["request", id],
queryFn: () => getRequestDetail(id),
});
};
export const useApproveRequest = () => {
return useMutation({
mutationFn: (id: string) => approveRequest(id),
});
};
export const useChangeRequestStatus = () => {
return useMutation({
mutationFn: ({
id,
status,
rejectReason,
}: {
id: string;
status: string;
rejectReason: string;
}) => {
if (status === "APPROVED") {
return approveRequest(id);
} else {
return rejectRequest(id, status, rejectReason);
}
},
});
};
@@ -0,0 +1,31 @@
import axios from "../../../config/axios";
export const getRequests = async (page: number = 1) => {
const { data } = await axios.get(`/companies/requests?page=${page}`);
return data;
};
export const getRequestDetail = async (id: string) => {
const { data } = await axios.get(`/companies/requests/${id}`);
return data;
};
export const approveRequest = async (id: string) => {
const { data } = await axios.patch(`/companies/requests/${id}/approve`);
return data;
};
export const rejectRequest = async (
id: string,
status: string,
rejectReason: string
) => {
const { data } = await axios.patch(
`/companies/requests/${id}/change-status`,
{
requestStatus: status,
rejectReason,
}
);
return data;
};
+48
View File
@@ -0,0 +1,48 @@
export type ApiResponse<T> = {
statusCode: number;
success: boolean;
data: T;
};
export type RequestResponse = {
request: RequestItemType;
};
export type RequestItemType = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
rejectReason: string | null;
requestStatus: string;
company: {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
name: string;
chiefExecutiveOfficer: string;
email: string;
phone: string;
identificationNumber: string;
dateOfEstablishment: string;
address: string;
mapAddressLink: string;
description: string;
websiteUrl: string;
profileImageUrl: string;
coverImageUrl: string;
isActive: boolean;
status: string;
industry: string;
business: string;
user: string;
invoiceCount: number;
};
business: string;
user: {
id: string;
firstName: string;
lastName: string;
};
};
+1 -17
View File
@@ -17,7 +17,6 @@ import { ErrorType } from '../../helpers/types'
import { clx } from '../../helpers/utils'
import { Pages } from '../../config/Pages'
import { useMultiUpload } from '../service/hooks/useServiceData'
import ReferTicket from './components/ReferTicket'
const TicketDetail: FC = () => {
@@ -99,22 +98,11 @@ const TicketDetail: FC = () => {
<div className='flex xl:flex-row flex-col-reverse gap-6 xl:mt-8 mt-6'>
<div className='flex-1 bg-white py-8 xl:px-10 px-4 rounded-3xl'>
<div className='gap-6 rowTwoInput'>
<Input
label={t('ticket.select_your_service')}
value={getMessages.data?.data?.ticket?.danakService?.name || '-'}
readOnly
/>
<Input
label={t('ticket.user_name')}
value={getMessages.data?.data?.ticket?.user?.firstName + ' ' + getMessages.data?.data?.ticket?.user?.lastName}
readOnly
/>
<Input
label={t('ticket.user_plan')}
value={getMessages.data?.data?.supportPlan?.userSupportPlan?.supportPlan?.name}
readOnly
/>
</div>
<div className='mt-8 xl:text-sm text-xs'>
@@ -123,7 +111,7 @@ const TicketDetail: FC = () => {
{
getMessages.data?.data?.messages.map((item: TicketMessageType) => {
if (item?.author?.roles[0]?.name === 'user') {
if (item?.author?.role?.name === 'USER') {
return (
<div key={item.id} className='mt-6 xl:text-sm text-xs bg-[#F6F7FA] p-6 rounded-3xl rounded-tr-none xl:max-w-[70%] max-w-[90%]'>
<div className='leading-7'>
@@ -309,10 +297,6 @@ const TicketDetail: FC = () => {
</div>
</div>
</div>
{
<ReferTicket refetch={getMessages.refetch} />
}
</div>
</div>
}
+2 -2
View File
@@ -54,9 +54,9 @@ export type TicketMessageType = {
id: string;
firstName: string;
lastName: string;
roles: {
role: {
name: string;
}[];
};
};
attachments: {
attachmentUrl: string;
+4
View File
@@ -50,6 +50,8 @@ import CreateCompany from '../pages/company/Create'
import Industries from '../pages/company/components/Industries'
import CompanyList from '../pages/company/List'
import UpdateCompany from '../pages/company/Update'
import RequestsList from '../pages/requests/List'
import RequestDetail from '../pages/requests/Detail'
const MainRouter: FC = () => {
const { hasSubMenu } = useSharedStore()
@@ -112,6 +114,8 @@ const MainRouter: FC = () => {
<Route path={Pages.company.industries} element={<Industries />} />
<Route path={Pages.company.list} element={<CompanyList />} />
<Route path={Pages.company.detail + ':id'} element={<UpdateCompany />} />
<Route path={Pages.requests.list} element={<RequestsList />} />
<Route path={Pages.requests.detail + ':id'} element={<RequestDetail />} />
</Routes>
</div>
</div>
+8 -2
View File
@@ -10,13 +10,14 @@ import { useSharedStore } from './store/sharedStore'
import { useGetProfile } from '../pages/profile/hooks/useProfileData'
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
import { removeRefreshToken, removeToken } from '../config/func'
import { clx } from '../helpers/utils'
const Header: FC = () => {
const location = useLocation();
const [popoverKey, setPopoverKey] = useState(0);
const { t } = useTranslation('global')
const { setOpenSidebar, openSidebar } = useSharedStore()
const { setOpenSidebar, openSidebar, hasSubMenu } = useSharedStore()
const { data } = useGetProfile()
useEffect(() => {
@@ -29,8 +30,13 @@ const Header: FC = () => {
window.location.href = Pages.auth.login
}
const hasSubMenuOpen = hasSubMenu && (openSidebar || window.innerWidth > 1139)
return (
<div className='fixed z-10 right-4 left-4 xl:right-[285px] top-4 xl:h-16 h-12 flex items-center px-6 bg-white justify-between rounded-[32px] xl:w-[calc(100%-305px)]'>
<div className={clx(
'fixed z-10 right-4 left-4 xl:right-[285px] top-4 xl:h-16 h-12 flex items-center px-6 bg-white justify-between rounded-[32px] xl:w-[calc(100%-305px)]',
hasSubMenuOpen && '!right-[320px] !w-[calc(100%-340px)]'
)}>
<div className='min-w-[270px] hidden xl:block'>
<Input