invoice + plan

This commit is contained in:
hamid zarghami
2025-02-15 16:22:42 +03:30
parent 65faa4de5f
commit 8f39afeb48
20 changed files with 1125 additions and 262 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ const Select: FC<Props> = (props: Props) => {
<option value="" disabled selected>{props.placeholder}</option> <option value="" disabled selected>{props.placeholder}</option>
} }
{ {
props.items.map((item) => { props.items?.map((item) => {
return ( return (
<option key={item.value} value={item.value}> <option key={item.value} value={item.value}>
{item.label} {item.label}
+1
View File
@@ -12,6 +12,7 @@ export const Pages = {
add: "/services/add", add: "/services/add",
list: "/services/list", list: "/services/list",
category: "/services/category", category: "/services/category",
plan: "/services/plan/",
}, },
transactions: "/transactions", transactions: "/transactions",
receipts: { receipts: {
+27 -2
View File
@@ -163,7 +163,22 @@
"deactive": "غیرفعال", "deactive": "غیرفعال",
"software_langauge": "زبان نرم افزار", "software_langauge": "زبان نرم افزار",
"link": "لینک", "link": "لینک",
"danak_sujescet": "پیشنهاد داناک" "danak_sujescet": "پیشنهاد داناک",
"plans": "پلن ها",
"add_plan": "افزودن پلن",
"active_plan": "پلن فعال"
},
"plan": {
"plans_list": "لیست پلن های ",
"new_plan": "پلن جدید",
"add_plan": "افزودن پلن",
"time": "مدت زمان",
"price": "قیمت",
"cancel": "لغو",
"submit_plan": "ثبت پلن",
"error_submit": "حداقل یک پلن باید اضافه شود",
"success_submit": "پلن با موفقیت ثبت شد",
"edit_plan": "ویرایش پلن"
}, },
"save": "ذخیره", "save": "ذخیره",
"search": "جستجو", "search": "جستجو",
@@ -361,7 +376,17 @@
"discount": "تخفیف(٪)", "discount": "تخفیف(٪)",
"total_amount": "مبلغ کل(ریال)", "total_amount": "مبلغ کل(ریال)",
"tax": "مالیات بر ارزش افزوده (۰.۹ درصد)", "tax": "مالیات بر ارزش افزوده (۰.۹ درصد)",
"total": "جمع" "total": "جمع",
"error_empty": "همه فیلد ها را پر کنید",
"date_receipt": "تاریخ صورتحساب",
"last_date_receipt": "آخرین مهلت پرداخت",
"service": "سرویس",
"status": "وضعیت تایید",
"status_paid": "وضعیت پرداخت",
"PENDING": "در انتظار",
"PAID": "پرداخت شده",
"CANCELED": "لغو شده",
"EXPIRED": "منقضی شده"
}, },
"edit": "ویرایش", "edit": "ویرایش",
"transaction": { "transaction": {
@@ -0,0 +1,9 @@
import * as api from "../service/CustomerService";
import { useQuery } from "@tanstack/react-query";
export const useGetCustomers = () => {
return useQuery({
queryKey: ["invoices"],
queryFn: () => api.getCustomers(),
});
};
@@ -0,0 +1,6 @@
import axios from "../../../config/axios";
export const getCustomers = async () => {
const { data } = await axios.get(`/users/customers`);
return data;
};
+20
View File
@@ -0,0 +1,20 @@
export type CustomerItemType = {
birthDate: string;
createdAt: string;
email: string | null;
firstName: string;
id: string;
invoices: unknown[];
lastName: string;
nationalCode: string;
phone: string;
role: {
id: string;
createdAt: string;
// Add other properties of role if needed
};
subscriptions: unknown[];
tickets: unknown[];
updatedAt: string;
userName: string | null;
};
+204 -12
View File
@@ -1,16 +1,86 @@
import { FC } from 'react' import { ChangeEvent, FC, Fragment, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Button from '../../components/Button' import Button from '../../components/Button'
import { Add, TickCircle, TickSquare } from 'iconsax-react' import { Add, TickCircle, TickSquare } from 'iconsax-react'
import Select from '../../components/Select' import Select from '../../components/Select'
import Input from '../../components/Input' import Input from '../../components/Input'
import { CreateReceiptType, ReceiptCreateItemsType } from './types/ReceiptTypes'
import { useFormik } from 'formik'
import * as Yup from 'yup'
import { toast } from 'react-toastify'
import { useGetCustomers } from '../customer/hooks/useCustomerData'
import { CustomerItemType } from '../customer/types/CustomerTypes'
import PageLoading from '../../components/PageLoading'
import { useCreateInvoice } from './hooks/useReceiptData'
import { useNavigate } from 'react-router-dom'
import { Pages } from '../../config/Pages'
import { ErrorType } from '../../helpers/types'
const CreateReceipt: FC = () => { const CreateReceipt: FC = () => {
const navigate = useNavigate()
const { t } = useTranslation('global') const { t } = useTranslation('global')
const [items, setItems] = useState<ReceiptCreateItemsType[]>([])
const [customer, setCustomer] = useState<CustomerItemType>()
const getCustomers = useGetCustomers()
const createInvoice = useCreateInvoice()
const formik = useFormik<ReceiptCreateItemsType>({
initialValues: {
name: '',
count: '',
unitPrice: '',
discount: ''
},
validationSchema: Yup.object({
name: Yup.string().required(t('errors.required')),
count: Yup.string().required(t('errors.required')),
unitPrice: Yup.string().required(t('errors.required')),
}),
onSubmit: (values) => {
setItems((prev) => [...prev, {
count: +values.count,
discount: +values.discount,
name: values.name,
unitPrice: +values.unitPrice
}])
formik.resetForm()
}
})
const handleChangeCustomer = (e: ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value
const customer = getCustomers.data?.data?.customers?.find((item: CustomerItemType) => item.id === value)
setCustomer(customer)
}
const handleSubmit = () => {
if (customer) {
const params: CreateReceiptType = {
userId: customer?.id,
items: items
}
createInvoice.mutate(params, {
onSuccess: () => {
toast.success(t('success'))
navigate(Pages.receipts.index)
},
onError(error: ErrorType) {
toast.error(error.response?.data?.error.message[0])
},
})
}
}
return ( return (
<div className='mt-4'> <div className='mt-4'>
{
getCustomers.isPending ?
<PageLoading />
:
<Fragment>
<div className='flex justify-between items-center'> <div className='flex justify-between items-center'>
<div> <div>
{t('receip.add_receip')} {t('receip.add_receip')}
@@ -18,6 +88,7 @@ const CreateReceipt: FC = () => {
<div> <div>
<Button <Button
className='px-5' className='px-5'
onClick={handleSubmit}
> >
<div className='flex gap-2 items-center'> <div className='flex gap-2 items-center'>
<TickCircle size={20} color='white' /> <TickCircle size={20} color='white' />
@@ -39,15 +110,20 @@ const CreateReceipt: FC = () => {
<div className='w-full xl:w-1/2'> <div className='w-full xl:w-1/2'>
<Select <Select
label={t('receip.customer')} label={t('receip.customer')}
items={[ placeholder={t('select')}
{ label: 'محمد', value: 'mohammad' }, items={getCustomers.data?.data?.customers?.map((item: CustomerItemType) => {
{ label: 'علی', value: 'ali' }, return {
{ label: 'حسین', value: 'hossein' }, label: item.firstName + ' ' + item.lastName,
]} value: item.id
}
})}
onChange={handleChangeCustomer}
/> />
</div> </div>
</div> </div>
{
customer &&
<div className='mt-10 bg-[#F6F7FB] p-8 rounded-3xl text-sm'> <div className='mt-10 bg-[#F6F7FB] p-8 rounded-3xl text-sm'>
<div className='flex justify-between items-center border-b border-border border-dashed pb-7'> <div className='flex justify-between items-center border-b border-border border-dashed pb-7'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
@@ -56,7 +132,7 @@ const CreateReceipt: FC = () => {
</div> </div>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<div className='text-description'>{t('receip.representativeـname')}</div> <div className='text-description'>{t('receip.representativeـname')}</div>
<div>مهرداد مظفری</div> <div>{customer.firstName + ' ' + customer.lastName}</div>
</div> </div>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<div className='text-description'>{t('receip.company_name')}</div> <div className='text-description'>{t('receip.company_name')}</div>
@@ -96,6 +172,7 @@ const CreateReceipt: FC = () => {
</div> </div>
</div> </div>
</div> </div>
}
<div className='mt-10'> <div className='mt-10'>
<div>{t('receip.receipt_information')}</div> <div>{t('receip.receipt_information')}</div>
@@ -107,7 +184,7 @@ const CreateReceipt: FC = () => {
{t('receip.number')} {t('receip.number')}
</div> </div>
<div className='size-10 bg-[#EBEDF5] rounded-xl flex justify-center items-center'> <div className='size-10 bg-[#EBEDF5] rounded-xl flex justify-center items-center'>
1 {items.length + 1}
</div> </div>
</div> </div>
@@ -117,6 +194,9 @@ const CreateReceipt: FC = () => {
</div> </div>
<Input <Input
className='text-center' className='text-center'
name='name'
value={formik.values.name}
onChange={formik.handleChange}
/> />
</div> </div>
@@ -127,6 +207,9 @@ const CreateReceipt: FC = () => {
<Input <Input
type='number' type='number'
className='w-16 text-center' className='w-16 text-center'
name='count'
value={formik.values.count}
onChange={formik.handleChange}
/> />
</div> </div>
@@ -136,6 +219,9 @@ const CreateReceipt: FC = () => {
</div> </div>
<Input <Input
className='text-center' className='text-center'
name='unitPrice'
value={formik.values.unitPrice}
onChange={formik.handleChange}
/> />
</div> </div>
@@ -146,6 +232,9 @@ const CreateReceipt: FC = () => {
<Input <Input
type='number' type='number'
className='w-16 text-center' className='w-16 text-center'
name='discount'
value={formik.values.discount}
onChange={formik.handleChange}
/> />
</div> </div>
@@ -155,13 +244,112 @@ const CreateReceipt: FC = () => {
</div> </div>
<Input <Input
className='text-center bg-[#EBEDF5]' className='text-center bg-[#EBEDF5]'
readOnly
/> />
</div> </div>
<div className='size-10 border border-border rounded-xl flex justify-center items-center'> <div onClick={() => {
if (formik.errors.name || formik.errors.count || formik.errors.unitPrice || formik.errors.discount) {
toast.error(t('receip.error_empty'))
} else {
formik.handleSubmit()
}
}} className='size-10 border border-border rounded-xl flex justify-center items-center'>
<Add size={20} color='black' />
</div>
</div>
{
items.map((item, index: number) => {
return (
<div key={item.name} className='mt-8 flex items-end justify-between text-xs text-description'>
<div className='flex flex-col gap-2 items-center'>
<div>
{t('receip.number')}
</div>
<div className='size-10 bg-[#EBEDF5] rounded-xl flex justify-center items-center'>
{index + 1}
</div>
</div>
<div className='flex flex-col gap-2 items-center'>
<div>
{t('receip.product_name')}
</div>
<Input
className='text-center'
name='name'
readOnly
value={item.name}
/>
</div>
<div className='flex flex-col gap-2 items-center'>
<div>
{t('receip.count')}
</div>
<Input
type='number'
className='w-16 text-center'
name='count'
readOnly
value={item.count}
/>
</div>
<div className='flex flex-col gap-2 items-center'>
<div>
{t('receip.unit_amount')}
</div>
<Input
className='text-center'
name='unitPrice'
readOnly
value={item.unitPrice}
/>
</div>
<div className='flex flex-col gap-2 items-center'>
<div>
{t('receip.discount')}
</div>
<Input
type='number'
className='w-16 text-center'
name='discount'
readOnly
value={item.discount}
/>
</div>
<div className='flex flex-col gap-2 items-center'>
<div>
{t('receip.total_amount')}
</div>
<Input
className='text-center bg-[#EBEDF5]'
readOnly
value={((Number(item.unitPrice) || 0) * (Number(item.count) || 0) - (Number(item.unitPrice) || 0) * (Number(item.count) || 0) * (Number(item.discount) / 100 || 0)).toFixed(1)}
/>
</div>
<div onClick={() => {
if (formik.errors.name || formik.errors.count || formik.errors.unitPrice || formik.errors.discount) {
toast.error(t('receip.error_empty'))
} else {
formik.handleSubmit()
}
}} className='size-10 border border-border rounded-xl flex justify-center items-center'>
<Add size={20} color='black' /> <Add size={20} color='black' />
</div> </div>
</div> </div>
)
})
}
<div className='mt-8 flex flex-col gap-4 items-end'> <div className='mt-8 flex flex-col gap-4 items-end'>
<div className='xl:w-1/2 h-12 w-full rounded-xl flex px-4 bg-[#EBEDF5] text-sm text-description justify-between items-center'> <div className='xl:w-1/2 h-12 w-full rounded-xl flex px-4 bg-[#EBEDF5] text-sm text-description justify-between items-center'>
@@ -169,7 +357,7 @@ const CreateReceipt: FC = () => {
{t('receip.tax')} {t('receip.tax')}
</div> </div>
<div className='text-black'> <div className='text-black'>
100,000 {((items.reduce((acc, item) => acc + (Number(item.unitPrice) || 0) * (Number(item.count) || 0) - (Number(item.unitPrice) || 0) * (Number(item.count) || 0) * (Number(item.discount) / 100 || 0), 0)) * 0.09).toFixed(1)}
</div> </div>
</div> </div>
<div className='xl:w-1/2 h-12 w-full rounded-xl flex px-4 bg-[#EBEDF5] text-sm text-description justify-between items-center'> <div className='xl:w-1/2 h-12 w-full rounded-xl flex px-4 bg-[#EBEDF5] text-sm text-description justify-between items-center'>
@@ -177,14 +365,16 @@ const CreateReceipt: FC = () => {
{t('receip.total')} {t('receip.total')}
</div> </div>
<div className='text-black'> <div className='text-black'>
100,000 {(
items.reduce((acc, item) => acc + (Number(item.unitPrice) || 0) * (Number(item.count) || 0) - (Number(item.unitPrice) || 0) * (Number(item.count) || 0) * (Number(item.discount) / 100 || 0), 0) * 1.09
).toFixed(1)}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className='bg-white w-sidebar xl:block hidden py-10 px-5 h-fit rounded-3xl'> <div className='bg-white w-sidebar 3xl:block hidden py-10 px-5 h-fit rounded-3xl'>
<div className='text-sm'> <div className='text-sm'>
{t('ticket.title_hint')} {t('ticket.title_hint')}
</div> </div>
@@ -217,6 +407,8 @@ const CreateReceipt: FC = () => {
</div> </div>
</div> </div>
</div> </div>
</Fragment>
}
</div> </div>
) )
} }
+45 -55
View File
@@ -1,15 +1,18 @@
import { FC, useState } from 'react' import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Tabs from '../../components/Tabs' import Tabs from '../../components/Tabs'
import { Calendar, CloseCircle, Eye, FolderOpen, MoneyChange, WalletCheck, WalletRemove, WalletSearch } from 'iconsax-react' import { CloseCircle, FolderOpen, WalletCheck, WalletRemove } from 'iconsax-react'
import Td from '../../components/Td' import Td from '../../components/Td'
import { Link } from 'react-router-dom' import { useGetInvoices } from './hooks/useReceiptData'
import { Pages } from '../../config/Pages' import PageLoading from '../../components/PageLoading'
import { ReceiptItemType } from './types/ReceiptTypes'
import { NumberFormat } from '../../config/func'
const ReceiptsList: FC = () => { const ReceiptsList: FC = () => {
const { t } = useTranslation('global') const { t } = useTranslation('global')
const [activeTab, setActiveTab] = useState<string>('unpaid') const [activeTab, setActiveTab] = useState<"PENDING" | "PAID" | "CANCELED" | "EXPIRED">('PENDING')
const getInvoices = useGetInvoices(activeTab)
return ( return (
<div className='mt-4'> <div className='mt-4'>
@@ -22,87 +25,74 @@ const ReceiptsList: FC = () => {
active={activeTab} active={activeTab}
items={[ items={[
{ {
icon: <WalletRemove color={activeTab === 'unpaid' ? 'black' : '#8C90A3'} size={22} />, icon: <WalletRemove color={activeTab === 'PENDING' ? 'black' : '#8C90A3'} size={22} />,
label: t('receip.dont_pay'), label: t('receip.dont_pay'),
value: 'unpaid' value: 'PENDING'
}, },
{ {
icon: <WalletSearch color={activeTab === 'not_confimed' ? 'black' : '#8C90A3'} size={22} />, icon: <WalletCheck color={activeTab === 'PAID' ? 'black' : '#8C90A3'} size={22} />,
label: t('receip.dont_confrim'),
value: 'not_confimed'
},
{
icon: <WalletCheck color={activeTab === 'paid' ? 'black' : '#8C90A3'} size={22} />,
label: t('receip.paid'), label: t('receip.paid'),
value: 'paid' value: 'PAID'
}, },
{ {
icon: <CloseCircle color={activeTab === 'canceled' ? 'black' : '#8C90A3'} size={22} />, icon: <CloseCircle color={activeTab === 'CANCELED' ? 'black' : '#8C90A3'} size={22} />,
label: t('receip.canceled'), label: t('receip.canceled'),
value: 'canceled' value: 'CANCELED'
}, },
{ {
icon: <MoneyChange color={activeTab === 'returned' ? 'black' : '#8C90A3'} size={22} />, icon: <FolderOpen color={activeTab === 'EXPIRED' ? 'black' : '#8C90A3'} size={22} />,
label: t('receip.returned'),
value: 'returned'
},
{
icon: <Calendar color={activeTab === 'time' ? 'black' : '#8C90A3'} size={22} />,
label: t('receip.its_time'),
value: 'time'
},
{
icon: <FolderOpen color={activeTab === 'archive' ? 'black' : '#8C90A3'} size={22} />,
label: t('receip.archive'), label: t('receip.archive'),
value: 'archive' value: 'EXPIRED'
}, },
]} ]}
onChange={setActiveTab} onChange={(value) => setActiveTab(value as "PENDING" | "PAID" | "CANCELED" | "EXPIRED")}
/> />
</div> </div>
{
getInvoices.isPending ?
<div className='mt-5'>
<PageLoading />
</div>
:
<div className='relative overflow-x-auto rounded-3xl mt-9 w-full'> <div className='relative overflow-x-auto rounded-3xl mt-9 w-full'>
<table className='w-full text-sm '> <table className='w-full text-sm '>
<thead className='thead'> <thead className='thead'>
<tr> <tr>
<Td text={t('ticket.number')} /> <Td text={t('ticket.number')} />
<Td text={t('ticket.title')} /> <Td text={t('receip.customer')} />
<Td text={t('ticket.team')} /> <Td text={t('receip.date_receipt')} />
<Td text={t('ticket.date')} /> <Td text={t('receip.last_date_receipt')} />
<Td text={t('ticket.status')} /> <Td text={t('receip.total')} />
<Td text={t('ticket.priority')} /> <Td text={t('receip.service')} />
<Td text={t('ticket.detail')} /> <Td text={t('receip.status')} />
<Td text={t('receip.status_paid')} />
<Td text={''} /> <Td text={''} />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{
getInvoices.data?.data?.invoices?.map((item: ReceiptItemType, index: number) => {
return (
<tr className='tr'> <tr className='tr'>
<Td text={t('ticket.number')} /> <Td text={String(index + 1)} />
<Td text={t('ticket.title')} /> <Td text={item.user.firstName + ' ' + item.user.lastName} />
<Td text={t('ticket.team')} /> <Td text={item.createdAt} />
<Td text={t('ticket.date')} /> <Td text={item.dueDate} />
<Td text={t('ticket.status')} /> <Td text={NumberFormat(item.totalPrice)} />
<Td text={t('ticket.priority')} /> <Td text={t('receip.service')} />
<Td text={t('ticket.detail')} /> <Td text={t(`receip.${item.status}`)} />
<Td text={''}> <Td text={item.paidAt ? t('receip.paid') : t('receip.not_paid')} />
<Link to={Pages.receipts.detail + '1'}>
<Eye size={20} color='black' />
</Link>
</Td>
</tr>
<tr className='tr'>
<Td text={t('ticket.number')} />
<Td text={t('ticket.title')} />
<Td text={t('ticket.team')} />
<Td text={t('ticket.date')} />
<Td text={t('ticket.status')} />
<Td text={t('ticket.priority')} />
<Td text={t('ticket.detail')} />
<Td text={''} /> <Td text={''} />
</tr> </tr>
)
})
}
</tbody> </tbody>
</table> </table>
</div> </div>
}
</div> </div>
) )
} }
@@ -0,0 +1,16 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import * as api from "../service/ReceiptService";
import { CreateReceiptType } from "../types/ReceiptTypes";
export const useGetInvoices = (status: string) => {
return useQuery({
queryKey: ["invoices", status],
queryFn: () => api.getInvoces(status),
});
};
export const useCreateInvoice = () => {
return useMutation({
mutationFn: (variables: CreateReceiptType) => api.createInvoice(variables),
});
};
@@ -0,0 +1,12 @@
import axios from "../../../config/axios";
import { CreateReceiptType } from "../types/ReceiptTypes";
export const getInvoces = async (status: string) => {
const { data } = await axios.get(`/invoices?status=${status}`);
return data;
};
export const createInvoice = async (params: CreateReceiptType) => {
const { data } = await axios.post(`/invoices`, params);
return data;
};
+31
View File
@@ -0,0 +1,31 @@
export type CreateReceiptType = {
userId: string;
items: ReceiptCreateItemsType[];
};
export type ReceiptCreateItemsType = {
name: string;
count: string | number;
unitPrice: string | number;
discount: string | number;
};
export type ReceiptItemType = {
id: string;
createdAt: string;
updatedAt: string;
name: string;
count: string | number;
unitPrice: string | number;
discount: string | number;
totalPrice: number;
subscriptionPlan?: string | null;
items: ReceiptItemType[];
user: {
firstName: string;
lastName: string;
};
dueDate: string;
status: "PENDING" | "PAID" | "CANCELED" | "EXPIRED";
paidAt: string;
};
+21
View File
@@ -107,6 +107,7 @@ const ListService: FC = () => {
<Td text={t('service.company_developed')} /> <Td text={t('service.company_developed')} />
<Td text={t('service.category')} /> <Td text={t('service.category')} />
<Td text={t('service.publish_date')} /> <Td text={t('service.publish_date')} />
<Td text={t('service.plans')} />
<Td text={t('ticket.status')} /> <Td text={t('ticket.status')} />
<Td text={t('ticket.detail')} /> <Td text={t('ticket.detail')} />
<Td text={''} /> <Td text={''} />
@@ -126,6 +127,26 @@ const ListService: FC = () => {
<Td text={item.author} /> <Td text={item.author} />
<Td text={item.category?.title} /> <Td text={item.category?.title} />
<Td text={moment(item.createDate).format('jYYYY-jMM-jDD')} /> <Td text={moment(item.createDate).format('jYYYY-jMM-jDD')} />
<Td text=''>
{
item.subscriptionCount === 0 ?
<Link to={Pages.services.plan + item.id}>
<Button
className='h-8 bg-transparent border border-black text-black text-xs'
>
<div className='flex gap-2 items-center'>
<Add size={20} color='black' />
<div>{t('service.add_plan')}</div>
</div>
</Button>
</Link>
:
<Link to={Pages.services.plan + item.id} className='flex gap-1 text-sm text-[#0047FF]'>
<div>{item.subscriptionCount}</div>
<div>{t('service.active_plan')}</div>
</Link>
}
</Td>
<Td text={''}> <Td text={''}>
<ToggleStatusService <ToggleStatusService
id={item.id} id={item.id}
+71
View File
@@ -0,0 +1,71 @@
import { FC, Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import CreatePlan from './components/CreatePlan'
import { useGetPlans } from './hooks/useServiceData'
import { useParams } from 'react-router-dom'
import PageLoading from '../../components/PageLoading'
import Td from '../../components/Td'
import { PlanItemType } from './types/ServiceTypes'
import ToggleStatusPlan from './components/ToggleStatusPlan'
import EditPlan from './components/EditPlan'
const Plans: FC = () => {
const { id } = useParams()
const { t } = useTranslation('global')
const getPlans = useGetPlans(id)
return (
<div className='mt-4'>
{
getPlans.isPending ?
<PageLoading />
:
<Fragment>
<div className='flex w-full justify-between items-center'>
<div>
{t('plan.plans_list') + ' ' + getPlans.data?.data?.service?.name}
</div>
<CreatePlan />
</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('title')} />
<Td text={t('plan.time')} />
<Td text={t('plan.price')} />
<Td text={t('status')} />
<Td text={''} />
</tr>
</thead>
<tbody>
{
getPlans.data?.data?.subscriptions.map((item: PlanItemType) => (
<tr key={item.id} className='tr'>
<Td text={item.name} />
<Td text={item.duration + ''} />
<Td text={item.price + ''} />
<Td text={''}>
<ToggleStatusPlan defaultActive={item.isActive} id={item.id} />
</Td>
<Td text=''>
<EditPlan
id={item.id}
/>
</Td>
</tr>
))
}
</tbody>
</table>
</div>
</Fragment>
}
</div>
)
}
export default Plans
+194
View File
@@ -0,0 +1,194 @@
import { FC, useState } from 'react'
import Button from '../../../components/Button'
import { Add, AddCircle, TickCircle, Trash } from 'iconsax-react'
import { useTranslation } from 'react-i18next'
import DefaulModal from '../../../components/DefaulModal'
import Input from '../../../components/Input'
import { useFormik } from 'formik'
import { CreateItemPlanType, CreatePlanType } from '../types/ServiceTypes'
import * as Yup from 'yup'
import { clx } from '../../../helpers/utils'
import { toast } from 'react-toastify'
import { useCreatePlan, useGetPlans } from '../hooks/useServiceData'
import { useParams } from 'react-router-dom'
import { ErrorType } from '../../../helpers/types'
const CreatePlan: FC = () => {
const { id } = useParams()
const { t } = useTranslation('global')
const [showModal, setShowModal] = useState<boolean>(false)
const [items, setItems] = useState<CreateItemPlanType[]>([])
const getPlans = useGetPlans(id)
const createPlan = useCreatePlan()
const formik = useFormik<CreateItemPlanType>({
initialValues: {
name: '',
duration: '',
isActive: true,
price: ''
},
validationSchema: Yup.object({
name: Yup.string()
.required(t('errors.required')),
duration: Yup.string()
.required(t('errors.required')),
price: Yup.string()
.required(t('errors.required')),
}),
onSubmit: (values) => {
const vals = {
...values,
duration: +values.duration,
price: +values.price,
}
setItems([...items, vals])
formik.resetForm()
}
})
const handleDeleteItem = (index: number) => {
const newItems = items.filter((_, i) => i !== index)
setItems(newItems)
}
const handleSubmit = () => {
if (items.length === 0) {
toast.error(t('plan.error_submit'))
} else {
const params: CreatePlanType = {
serviceId: id ? id : '',
subs: items
}
createPlan.mutate(params, {
onSuccess: () => {
formik.resetForm()
setShowModal(false)
toast.success(t('plan.success_submit'))
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error?.message[0])
}
})
}
}
return (
<div>
<Button
className='px-5'
onClick={() => setShowModal(true)}
>
<div className='flex gap-2'>
<Add
className='size-5'
color='#fff'
/>
<div>{t('plan.new_plan')}</div>
</div>
</Button>
<DefaulModal
open={showModal}
close={() => setShowModal(false)}
title_header={t('plan.add_plan') + ' ' + getPlans.data?.data?.service?.name}
isHeader
>
<div className='mt-6'>
{
items.map((item, index: number) => {
return (
<div className='flex justify-between items-center gap-8 mt-4'>
<Input
className='bg-description bg-opacity-15 border-none'
value={item.name}
readOnly
/>
<Input
className='bg-description bg-opacity-15 border-none'
value={item.duration}
readOnly
/>
<Input
className='bg-description bg-opacity-15 border-none'
value={item.price}
readOnly
/>
<Trash
size={24}
color='#D52903'
className='min-w-6'
onClick={() => handleDeleteItem(index)}
/>
</div>
)
})
}
<div className={clx(
'flex justify-between items-start gap-8',
items.length > 0 && 'mt-8',
)}>
<Input
label={t('title')}
className='bg-white bg-opacity-30'
name='name'
value={formik.values.name}
onChange={formik.handleChange}
error_text={formik.touched.name && formik.errors.name ? formik.errors.name : ''}
/>
<Input
label={t('plan.time')}
className='bg-white bg-opacity-30'
name='duration'
value={formik.values.duration}
onChange={formik.handleChange}
error_text={formik.touched.duration && formik.errors.duration ? formik.errors.duration : ''}
/>
<Input
label={t('plan.price')}
className='bg-white bg-opacity-30'
name='price'
value={formik.values.price}
onChange={formik.handleChange}
error_text={formik.touched.price && formik.errors.price ? formik.errors.price : ''}
/>
<div onClick={() => formik.handleSubmit()} className='size-10 min-w-10 mt-7 rounded-xl border border-black flex justify-center items-center'>
<AddCircle size={20} color='black' />
</div>
</div>
<div className='mt-12 border-t border-border pt-5 flex justify-end'>
<div className='flex gap-5 items-center'>
<Button
className='w-[150px] bg-white bg-opacity-15 text-description'
label={t('plan.cancel')}
onClick={() => setShowModal(false)}
/>
<Button
onClick={handleSubmit}
>
<div className='flex gap-2'>
<TickCircle
size={20}
color='white'
/>
<div>
{t('plan.submit_plan')}
</div>
</div>
</Button>
</div>
</div>
</div>
</DefaulModal>
</div>
)
}
export default CreatePlan
+152
View File
@@ -0,0 +1,152 @@
import { FC, useEffect, useState } from 'react'
import Button from '../../../components/Button'
import { Edit, TickCircle } from 'iconsax-react'
import { useTranslation } from 'react-i18next'
import DefaulModal from '../../../components/DefaulModal'
import Input from '../../../components/Input'
import { useFormik } from 'formik'
import { CreateItemPlanType, UpdatePlanType } from '../types/ServiceTypes'
import * as Yup from 'yup'
import { clx } from '../../../helpers/utils'
import { toast } from 'react-toastify'
import { useGetPlanDetail, useUpdatePlan } from '../hooks/useServiceData'
import { useParams } from 'react-router-dom'
import { ErrorType } from '../../../helpers/types'
type Props = {
id: string,
}
const EditPlan: FC<Props> = (props: Props) => {
const { id } = useParams()
const { t } = useTranslation('global')
const [showModal, setShowModal] = useState<boolean>(false)
const getPlanDetail = useGetPlanDetail(showModal ? props.id : undefined)
const updatePlan = useUpdatePlan(props.id)
const formik = useFormik<CreateItemPlanType>({
initialValues: {
name: '',
duration: '',
isActive: true,
price: ''
},
validationSchema: Yup.object({
name: Yup.string()
.required(t('errors.required')),
duration: Yup.string()
.required(t('errors.required')),
price: Yup.string()
.required(t('errors.required')),
}),
onSubmit: (values) => {
const params: UpdatePlanType = {
serviceId: id,
duration: +values.duration,
price: +values.price,
name: values.name,
}
updatePlan.mutate(params, {
onSuccess: () => {
toast.success(t('success'))
setShowModal(false)
},
onError: (error: ErrorType) => {
toast.error(error.response?.data?.error?.message[0])
}
})
}
})
useEffect(() => {
if (getPlanDetail.data) {
formik.setValues({
name: getPlanDetail.data.data?.subscription.name,
duration: getPlanDetail.data.data?.subscription.duration + '',
price: getPlanDetail.data.data?.subscription.price + '',
isActive: getPlanDetail.data.data?.subscription.isActive
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getPlanDetail.data])
return (
<div>
<Edit
onClick={() => setShowModal(true)}
size={20}
color='#0047FF'
/>
<DefaulModal
open={showModal}
close={() => setShowModal(false)}
title_header={t('plan.edit_plan')}
isHeader
>
<div className='mt-6'>
<div className={clx(
'flex justify-between items-start gap-8',
)}>
<Input
label={t('title')}
className='bg-white bg-opacity-30'
name='name'
value={formik.values.name}
onChange={formik.handleChange}
error_text={formik.touched.name && formik.errors.name ? formik.errors.name : ''}
/>
<Input
label={t('plan.time')}
className='bg-white bg-opacity-30'
name='duration'
value={formik.values.duration}
onChange={formik.handleChange}
error_text={formik.touched.duration && formik.errors.duration ? formik.errors.duration : ''}
/>
<Input
label={t('plan.price')}
className='bg-white bg-opacity-30'
name='price'
value={formik.values.price}
onChange={formik.handleChange}
error_text={formik.touched.price && formik.errors.price ? formik.errors.price : ''}
/>
</div>
<div className='mt-12 border-t border-border pt-5 flex justify-end'>
<div className='flex gap-5 items-center'>
<Button
className='w-[150px] bg-white bg-opacity-15 text-description'
label={t('plan.cancel')}
onClick={() => setShowModal(false)}
/>
<Button
onClick={() => formik.handleSubmit()}
isLoading={updatePlan.isPending}
>
<div className='flex gap-2'>
<TickCircle
size={20}
color='white'
/>
<div>
{t('save')}
</div>
</div>
</Button>
</div>
</div>
</div>
</DefaulModal>
</div>
)
}
export default EditPlan
@@ -0,0 +1,28 @@
import { FC, useState } from 'react'
import SwitchComponent from '../../../components/Switch'
import { useToggleStatusPlan } from '../hooks/useServiceData'
type Props = {
defaultActive: boolean,
id: string
}
const ToggleStatusPlan: FC<Props> = (props: Props) => {
const [isActive, setIsActive] = useState<boolean>(props.defaultActive)
const toggleStatus = useToggleStatusPlan()
const handleChange = (value: boolean) => {
setIsActive(value)
toggleStatus.mutate(props.id)
}
return (
<SwitchComponent
active={isActive}
onChange={handleChange}
/>
)
}
export default ToggleStatusPlan
+36
View File
@@ -1,9 +1,11 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as api from "../service/ServiceService"; import * as api from "../service/ServiceService";
import { import {
CreatePlanType,
CreateServiceCategoryType, CreateServiceCategoryType,
CreateServiceType, CreateServiceType,
ToggleStatusCategoryType, ToggleStatusCategoryType,
UpdatePlanType,
} from "../types/ServiceTypes"; } from "../types/ServiceTypes";
export const useGetAllServices = ( export const useGetAllServices = (
@@ -80,3 +82,37 @@ export const useToggleStatusService = () => {
mutationFn: (id: string) => api.toggleStatusService(id), mutationFn: (id: string) => api.toggleStatusService(id),
}); });
}; };
export const useCreatePlan = () => {
return useMutation({
mutationFn: (variables: CreatePlanType) => api.createPlan(variables),
});
};
export const useGetPlans = (serviceId?: string) => {
return useQuery({
queryKey: ["plans", serviceId],
queryFn: () => api.getPlans(serviceId),
enabled: !!serviceId,
});
};
export const useToggleStatusPlan = () => {
return useMutation({
mutationFn: (variables: string) => api.planToggleStatus(variables),
});
};
export const useGetPlanDetail = (planId?: string) => {
return useQuery({
queryKey: ["plans", planId],
queryFn: () => api.getPlanDetail(planId),
enabled: !!planId,
});
};
export const useUpdatePlan = (id: string) => {
return useMutation({
mutationFn: (variables: UpdatePlanType) => api.updatePlan(id, variables),
});
};
@@ -1,8 +1,10 @@
import axios from "../../../config/axios"; import axios from "../../../config/axios";
import { import {
CreatePlanType,
CreateServiceCategoryType, CreateServiceCategoryType,
CreateServiceType, CreateServiceType,
ToggleStatusCategoryType, ToggleStatusCategoryType,
UpdatePlanType,
} from "../types/ServiceTypes"; } from "../types/ServiceTypes";
export const getAllServicess = async ( export const getAllServicess = async (
@@ -82,3 +84,28 @@ export const toggleStatusService = async (id: string) => {
const { data } = await axios.post(`/danak-services/toggle-status/${id}`); const { data } = await axios.post(`/danak-services/toggle-status/${id}`);
return data; return data;
}; };
export const createPlan = async (params: CreatePlanType) => {
const { data } = await axios.post(`/subscriptions`, params);
return data;
};
export const getPlans = async (serviceId?: string) => {
const { data } = await axios.get(`/subscriptions/service/${serviceId}`);
return data;
};
export const planToggleStatus = async (id: string) => {
const { data } = await axios.post(`/subscriptions/toggle-status/${id}`);
return data;
};
export const getPlanDetail = async (id?: string) => {
const { data } = await axios.get(`/subscriptions/${id}`);
return data;
};
export const updatePlan = async (id: string, params: UpdatePlanType) => {
const { data } = await axios.patch(`/subscriptions/${id}`, params);
return data;
};
+30
View File
@@ -50,4 +50,34 @@ export type ServiceItemType = {
createDate: string; createDate: string;
isActive: boolean; isActive: boolean;
icon: string; icon: string;
subscriptionCount: number;
};
export type CreateItemPlanType = {
name: string;
duration: string | number;
price: string | number;
isActive: boolean;
};
export type CreatePlanType = {
subs: CreateItemPlanType[];
serviceId: string;
};
export type PlanItemType = {
id: string;
name: string;
duration: string | number;
price: string | number;
isActive: boolean;
createdAt: string;
};
export type UpdatePlanType = {
name: string;
duration: string | number;
price: string | number;
isActive?: boolean;
serviceId?: string;
}; };
+2
View File
@@ -51,6 +51,7 @@ import EditBankCard from '../pages/cardBank/Edit'
import PaymentList from '../pages/payment/List' import PaymentList from '../pages/payment/List'
import LearningCategory from '../pages/learning/Category' import LearningCategory from '../pages/learning/Category'
import CreateLearning from '../pages/learning/Create' import CreateLearning from '../pages/learning/Create'
import Plans from '../pages/service/Plans'
const MainRouter: FC = () => { const MainRouter: FC = () => {
@@ -76,6 +77,7 @@ const MainRouter: FC = () => {
<Route path={Pages.services.add} element={<AddService />} /> <Route path={Pages.services.add} element={<AddService />} />
<Route path={Pages.services.list} element={<ListService />} /> <Route path={Pages.services.list} element={<ListService />} />
<Route path={Pages.services.category} element={<Category />} /> <Route path={Pages.services.category} element={<Category />} />
<Route path={Pages.services.plan + ':id'} element={<Plans />} />
<Route path={Pages.ticket.list} element={<TicketList />} /> <Route path={Pages.ticket.list} element={<TicketList />} />
<Route path={Pages.ticket.create} element={<CreateTicket />} /> <Route path={Pages.ticket.create} element={<CreateTicket />} />
<Route path={Pages.ticket.category} element={<TicketCategory />} /> <Route path={Pages.ticket.category} element={<TicketCategory />} />