add payments in invoice detail

This commit is contained in:
2026-05-24 10:43:35 +03:30
parent 28295dcc58
commit 7fa30e6e81
4 changed files with 388 additions and 101 deletions
+157 -99
View File
@@ -1,106 +1,164 @@
import { type FC } from 'react'
import Button from '@/components/Button'
import { AddSquare, TickSquare } from 'iconsax-react'
import Input from '@/components/Input'
import { COLORS } from '@/constants/colors'
import { Checkbox } from '@/components/ui/checkbox'
import UploadBox from '@/components/UploadBox'
import Textarea from '@/components/Textarea'
import { type FC } from 'react';
import { Link, useParams } from 'react-router-dom';
import moment from 'moment-jalaali';
import { Edit } from 'iconsax-react';
import { Paths } from '@/config/Paths';
import { useGetInvoiceDetail } from './hooks/useInvoiceData';
import InvoicePaymentsSection from './components/InvoicePaymentsSection';
const formatAmount = (amount: number) =>
Number(amount).toLocaleString('fa-IR') + ' تومان';
const DetailPerfomaInvoice: FC = () => {
const { id } = useParams<{ id: string }>();
const { data: invoiceData, isLoading } = useGetInvoiceDetail(id);
const invoice = invoiceData?.data;
if (isLoading || !invoice) {
return (
<div className='mt-5'>
<div className='flex justify-between items-center'>
<h1 className='text-lg font-light'>پیش فاکتور درخواست #123456</h1>
<Button
className='w-fit px-6'
>
<div className='flex gap-1.5'>
<TickSquare size={18} color='black' />
<div className='text-[13px]'>ثبت پیش فاکتور</div>
</div>
</Button>
<div className="mt-5 flex min-h-[200px] items-center justify-center">
<div className="text-[#888888]">در حال بارگذاری...</div>
</div>
);
}
const customerName =
invoice.user?.firstName && invoice.user?.lastName
? `${invoice.user.firstName} ${invoice.user.lastName}`
: invoice.user?.phone ?? '-';
return (
<div className="mt-5">
<div className="flex items-center justify-between">
<h1 className="text-lg font-light">
پیش فاکتور #{invoice.invoiceNumber}
</h1>
<Link to={Paths.perfomaInvoice.update + invoice.id}>
<button
type="button"
className="flex items-center gap-2 rounded-xl bg-primary px-5 py-2.5 text-sm hover:opacity-90"
>
<Edit size={18} color="black" />
ویرایش
</button>
</Link>
</div>
<div className="mt-8 rounded-3xl bg-white p-6">
<div className="font-light">اطلاعات پیش فاکتور</div>
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div>
<div className="mb-1.5 text-[13px] text-[#888888]">مشتری</div>
<div className="text-[15px]">{customerName}</div>
</div>
<div>
<div className="mb-1.5 text-[13px] text-[#888888]">تاریخ صدور</div>
<div className="text-[15px]">
{invoice.createdAt
? moment(invoice.createdAt).format('jYYYY/jMM/jDD')
: '-'}
</div>
<div className='mt-8 bg-white p-6 rounded-3xl'>
<div className='font-light'>اطلاعات پیش فاکتور</div>
<div className='mt-6 flex items-end gap-5'>
<Input
label='محصول'
readOnly
value='کارت ویزیت'
/>
<Input
label='توضیحات'
/>
<div>
<Input
label='تعداد'
className='w-[100px]'
/>
</div>
<div>
<Input
label='مبلغ واحد'
className='w-[100px]'
/>
</div>
<div>
<Input
label='تخفیف'
className='w-[100px]'
/>
</div>
<div>
<Input
label='مبلغ کل'
className='w-[100px]'
readOnly
/>
</div>
<AddSquare className='min-w-[30px]' size={40} color={COLORS.primary} />
</div>
<div className='mt-6 flex gap-2'>
<Checkbox
/>
<div className='text-[13px]'>مالیات بر ارزش افزوده</div>
</div>
<div className='mt-6'>
<UploadBox
label='فایل ضمیمه'
/>
</div>
<div className='mt-6'>
<Textarea
label='نحوه پرداخت'
placeholder='مبلغ 70 درصد پیش پرداخت / اعتبار 2 روز'
/>
</div>
<div className='mt-6'>
<Textarea
label='توضیحات'
placeholder='1- قیمت ها بدون احتساب مالیات بر ارزش افزوده می باشد
2- تیراژ تحویلی سفارشات با تقریب مثبت و منفی 15 % می باشد و مبلغ نهایی براساس تیراژ تحویلی محاسبه می گردد.
3- سفارشات چاپی با اختلاف رنگ 10 الی 15 درصد با فایل طراحی شده می باشد.'
/>
</div>
<div className='mt-6 bg-[#F5F7FC] h-12 px-3 flex justify-between items-center rounded-xl'>
<div className='text-[#888888] text-[13px]'>مبلغ کل:</div>
<div className='font-semibold text-[13px]'>۰ </div>
</div>
</div>
<div>
<div className="mb-1.5 text-[13px] text-[#888888]">مهلت تایید</div>
<div className="text-[15px]">
{invoice.approvalDeadline
? moment(invoice.approvalDeadline).format('jYYYY/jMM/jDD')
: '-'}
</div>
</div>
</div>
)
}
export default DetailPerfomaInvoice
<div className="mt-8 overflow-x-auto">
<table className="w-full min-w-[640px] text-right text-sm">
<thead>
<tr className="border-b border-border text-[13px] text-[#888888]">
<th className="pb-3 font-normal">محصول</th>
<th className="pb-3 font-normal">توضیحات</th>
<th className="pb-3 font-normal">تعداد</th>
<th className="pb-3 font-normal">مبلغ واحد</th>
<th className="pb-3 font-normal">تخفیف</th>
<th className="pb-3 font-normal">مبلغ کل</th>
</tr>
</thead>
<tbody>
{invoice.items?.map((item) => {
const lineTotal =
(item.quantity || 0) * (item.unitPrice || 0) -
(item.discount || 0);
return (
<tr key={item.id} className="border-b border-border/50">
<td className="py-3">{item.product?.title ?? '-'}</td>
<td className="py-3">{item.description || '-'}</td>
<td className="py-3">{item.quantity ?? '-'}</td>
<td className="py-3">
{item.unitPrice != null
? formatAmount(item.unitPrice)
: '-'}
</td>
<td className="py-3">
{item.discount != null
? formatAmount(item.discount)
: '-'}
</td>
<td className="py-3">{formatAmount(lineTotal)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{invoice.enableTax && (
<div className="mt-4 text-[13px] text-[#888888]">
مالیات بر ارزش افزوده (۱۰ درصد) فعال است
</div>
)}
{invoice.paymentMethod && (
<div className="mt-6">
<div className="mb-1.5 text-[13px] text-[#888888]">روش پرداخت</div>
<div className="whitespace-pre-wrap text-[15px]">
{invoice.paymentMethod}
</div>
</div>
)}
{invoice.description && (
<div className="mt-6">
<div className="mb-1.5 text-[13px] text-[#888888]">توضیحات</div>
<div className="whitespace-pre-wrap text-[15px]">
{invoice.description}
</div>
</div>
)}
<div className="mt-6 space-y-2">
<div className="flex h-12 items-center justify-between rounded-xl bg-[#F5F7FC] px-4">
<div className="text-[13px] text-[#888888]">مبلغ کل:</div>
<div className="text-[13px] font-semibold">
{formatAmount(invoice.total ?? 0)}
</div>
</div>
<div className="flex h-12 items-center justify-between rounded-xl bg-[#F5F7FC] px-4">
<div className="text-[13px] text-[#888888]">پرداخت شده:</div>
<div className="text-[13px] font-semibold text-green-700">
{formatAmount(invoice.paidAmount ?? 0)}
</div>
</div>
<div className="flex h-12 items-center justify-between rounded-xl bg-[#F5F7FC] px-4">
<div className="text-[13px] text-[#888888]">مانده:</div>
<div className="text-[13px] font-semibold">
{formatAmount(invoice.balance ?? 0)}
</div>
</div>
</div>
</div>
{id && <InvoicePaymentsSection invoiceId={id} />}
</div>
);
};
export default DetailPerfomaInvoice;
+5 -2
View File
@@ -3,7 +3,7 @@ import { useMemo, useState, type FC } from 'react'
import { ProformaInvoiceStatusEnum } from './enum/InvoiceEnum'
import Filters from '@/components/Filters'
import Table from '@/components/Table'
import { Eye, Add } from 'iconsax-react'
import { Eye, Add, Edit2 } from 'iconsax-react'
import { Link } from 'react-router-dom'
import { Paths } from '@/config/Paths'
import moment from 'moment-jalaali'
@@ -180,9 +180,12 @@ const ProformaInvoice: FC = () => {
title: '',
render: (item) => (
<div className='flex gap-2 items-center'>
<Link to={Paths.perfomaInvoice.update + item.id}>
<Link to={Paths.perfomaInvoice.detail + item.id}>
<Eye size={20} color='#8C90A3' />
</Link>
<Link to={Paths.perfomaInvoice.update + item.id}>
<Edit2 size={20} color='#0037FF' />
</Link>
</div>
),
},
@@ -0,0 +1,223 @@
import { type FC, useState } from 'react';
import moment from 'moment-jalaali';
import Table from '@/components/Table';
import ModalConfrim from '@/components/ModalConfrim';
import type { ColumnType } from '@/components/types/TableTypes';
import { toast } from '@/shared/toast';
import { extractErrorMessage } from '@/config/func';
import {
useConfirmCashPayment,
useDenyCashPayment,
useGetPayments,
useVerifyOnlinePayment,
} from '@/pages/payment/hooks/usePaymentData';
import {
PaymentMethodEnum,
PaymentStatusEnum,
} from '@/pages/payment/enum/PaymentEnum';
import type { PaymentType } from '@/pages/payment/types/Types';
type Props = {
invoiceId: string;
};
const methodLabels: Record<PaymentMethodEnum, string> = {
[PaymentMethodEnum.Online]: 'آنلاین',
[PaymentMethodEnum.Cash]: 'نقدی',
[PaymentMethodEnum.Credit]: 'اعتبار',
};
const statusLabels: Record<PaymentStatusEnum, string> = {
[PaymentStatusEnum.Pending]: 'در انتظار',
[PaymentStatusEnum.Paid]: 'پرداخت شده',
[PaymentStatusEnum.Failed]: 'ناموفق',
[PaymentStatusEnum.Refunded]: 'بازگشت داده شده',
};
const statusBadgeClass: Record<PaymentStatusEnum, string> = {
[PaymentStatusEnum.Pending]: 'bg-amber-100 text-amber-800',
[PaymentStatusEnum.Paid]: 'bg-green-100 text-green-800',
[PaymentStatusEnum.Failed]: 'bg-red-100 text-red-800',
[PaymentStatusEnum.Refunded]: 'bg-gray-100 text-gray-800',
};
const formatAmount = (amount: number) =>
Number(amount).toLocaleString('fa-IR') + ' تومان';
const InvoicePaymentsSection: FC<Props> = ({ invoiceId }) => {
const [page, setPage] = useState(1);
const [confirmAction, setConfirmAction] = useState<{
type: 'cash-confirm' | 'cash-deny' | 'online-verify';
payment: PaymentType;
} | null>(null);
const { data, isLoading } = useGetPayments({
page,
limit: 10,
invoiceId,
});
const confirmCash = useConfirmCashPayment();
const denyCash = useDenyCashPayment();
const verifyOnline = useVerifyOnlinePayment();
const payments = data?.data ?? [];
const meta = data?.meta;
const isActionLoading =
confirmCash.isPending || denyCash.isPending || verifyOnline.isPending;
const handleConfirmAction = () => {
if (!confirmAction) return;
const { type, payment } = confirmAction;
const onSuccess = () => {
toast('عملیات با موفقیت انجام شد', 'success');
setConfirmAction(null);
};
const onError = (error: unknown) => {
toast(extractErrorMessage(error), 'error');
};
if (type === 'cash-confirm') {
confirmCash.mutate(payment.id, { onSuccess, onError });
return;
}
if (type === 'cash-deny') {
denyCash.mutate(payment.id, { onSuccess, onError });
return;
}
if (type === 'online-verify' && payment.token) {
verifyOnline.mutate(payment.token, { onSuccess, onError });
}
};
const getConfirmLabel = () => {
if (!confirmAction) return '';
switch (confirmAction.type) {
case 'cash-confirm':
return 'آیا از تایید این پرداخت نقدی اطمینان دارید؟';
case 'cash-deny':
return 'آیا از رد این پرداخت نقدی اطمینان دارید؟';
case 'online-verify':
return 'آیا از تایید این پرداخت آنلاین اطمینان دارید؟';
default:
return '';
}
};
const columns: ColumnType<PaymentType>[] = [
{
title: 'مبلغ',
key: 'amount',
render: (item) => formatAmount(item.amount),
},
{
title: 'روش پرداخت',
key: 'method',
render: (item) => methodLabels[item.method] ?? item.method,
},
{
title: 'وضعیت',
key: 'status',
render: (item) => (
<span
className={`inline-flex h-6 items-center rounded-full px-2.5 text-xs ${statusBadgeClass[item.status] ?? 'bg-gray-100 text-gray-800'}`}
>
{statusLabels[item.status] ?? item.status}
</span>
),
},
{
title: 'تاریخ',
key: 'createdAt',
render: (item) =>
item.createdAt
? moment(item.createdAt).format('jYYYY/jMM/jDD HH:mm')
: '-',
},
{
title: 'عملیات',
key: 'actions',
render: (item) => {
const isPending = item.status === PaymentStatusEnum.Pending;
const isCash = item.method === PaymentMethodEnum.Cash;
const isOnline = item.method === PaymentMethodEnum.Online;
return (
<div className="flex flex-wrap items-center gap-2">
{isPending && isCash && (
<>
<button
type="button"
className="rounded-lg bg-green-600 px-2 py-1 text-xs text-white hover:opacity-90"
onClick={() =>
setConfirmAction({ type: 'cash-confirm', payment: item })
}
>
تایید
</button>
<button
type="button"
className="rounded-lg bg-red-600 px-2 py-1 text-xs text-white hover:opacity-90"
onClick={() =>
setConfirmAction({ type: 'cash-deny', payment: item })
}
>
رد
</button>
</>
)}
{isPending && isOnline && item.token && (
<button
type="button"
className="rounded-lg bg-primary px-2 py-1 text-xs text-black hover:opacity-90"
onClick={() =>
setConfirmAction({ type: 'online-verify', payment: item })
}
>
تایید آنلاین
</button>
)}
</div>
);
},
},
];
return (
<>
<div className="bg-white rounded-3xl p-6 mt-8">
<div className="font-light">پرداختها</div>
<div className="mt-6">
<Table
columns={columns}
data={payments}
isLoading={isLoading}
noDataMessage="پرداختی برای این پیش‌فاکتور یافت نشد."
pagination={
meta && meta.totalPages > 1
? {
currentPage: meta.page,
totalPages: meta.totalPages,
onPageChange: setPage,
}
: undefined
}
/>
</div>
</div>
<ModalConfrim
isOpen={!!confirmAction}
close={() => setConfirmAction(null)}
onConfrim={handleConfirmAction}
isloading={isActionLoading}
label={getConfirmLabel()}
/>
</>
);
};
export default InvoicePaymentsSection;
@@ -15,6 +15,7 @@ export const useConfirmCashPayment = () => {
mutationFn: (paymentId: string) => api.confirmCashPayment(paymentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payments'] });
queryClient.invalidateQueries({ queryKey: ['invoice'] });
},
});
};
@@ -25,6 +26,7 @@ export const useDenyCashPayment = () => {
mutationFn: (paymentId: string) => api.denyCashPayment(paymentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payments'] });
queryClient.invalidateQueries({ queryKey: ['invoice'] });
},
});
};
@@ -35,6 +37,7 @@ export const useVerifyOnlinePayment = () => {
mutationFn: (token: string) => api.verifyOnlinePayment(token),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payments'] });
queryClient.invalidateQueries({ queryKey: ['invoice'] });
},
});
};