add payment page
This commit is contained in:
@@ -10,6 +10,14 @@ WORKDIR /build
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . ./
|
||||
|
||||
ARG VITE_API_BASE_URL
|
||||
ARG VITE_TOKEN_NAME
|
||||
ARG VITE_REFRESH_TOKEN_NAME
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
||||
VITE_TOKEN_NAME=$VITE_TOKEN_NAME \
|
||||
VITE_REFRESH_TOKEN_NAME=$VITE_REFRESH_TOKEN_NAME
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:stable-alpine AS production-stage
|
||||
|
||||
@@ -63,6 +63,9 @@ export const Paths = {
|
||||
detail: '/invoice/',
|
||||
update: '/invoice/update/',
|
||||
},
|
||||
payments: {
|
||||
list: '/payments',
|
||||
},
|
||||
newOrder: '/new-order',
|
||||
orderDetails: '/order/',
|
||||
tickets: {
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { type FC, useMemo, useState } from 'react';
|
||||
import moment from 'moment-jalaali';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Eye } from 'iconsax-react';
|
||||
import Tabs from '@/components/Tabs';
|
||||
import Table from '@/components/Table';
|
||||
import Filters, { type FilterValues } from '@/components/Filters';
|
||||
import ModalConfrim from '@/components/ModalConfrim';
|
||||
import type { ColumnType } from '@/components/types/TableTypes';
|
||||
import { Paths } from '@/config/Paths';
|
||||
import { toast } from '@/shared/toast';
|
||||
import { extractErrorMessage } from '@/config/func';
|
||||
import {
|
||||
useConfirmCashPayment,
|
||||
useDenyCashPayment,
|
||||
useGetPayments,
|
||||
useVerifyOnlinePayment,
|
||||
} from './hooks/usePaymentData';
|
||||
import {
|
||||
PaymentMethodEnum,
|
||||
PaymentStatusEnum,
|
||||
} from './enum/PaymentEnum';
|
||||
import type { PaymentType } from './types/Types';
|
||||
|
||||
type StatusTab = '' | PaymentStatusEnum;
|
||||
|
||||
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 getCustomerName = (item: PaymentType) => {
|
||||
const user = item.invoice?.user;
|
||||
if (!user) return '-';
|
||||
const name = [user.firstName, user.lastName].filter(Boolean).join(' ');
|
||||
return name || user.phone || '-';
|
||||
};
|
||||
|
||||
const PaymentsList: FC = () => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [activeTab, setActiveTab] = useState<StatusTab>('');
|
||||
const [filters, setFilters] = useState<FilterValues>({});
|
||||
const [confirmAction, setConfirmAction] = useState<{
|
||||
type: 'cash-confirm' | 'cash-deny' | 'online-verify';
|
||||
payment: PaymentType;
|
||||
} | null>(null);
|
||||
|
||||
const statuses = useMemo(() => {
|
||||
if (!activeTab) return undefined;
|
||||
return [activeTab];
|
||||
}, [activeTab]);
|
||||
|
||||
const search = (filters.search ?? '').trim() || undefined;
|
||||
|
||||
const { data, isLoading } = useGetPayments({
|
||||
page,
|
||||
limit: 10,
|
||||
search,
|
||||
statuses,
|
||||
});
|
||||
|
||||
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: 'invoiceNumber',
|
||||
render: (item) => item.invoice?.invoiceNumber ?? '-',
|
||||
},
|
||||
{
|
||||
title: 'مشتری',
|
||||
key: 'customer',
|
||||
render: (item) => getCustomerName(item),
|
||||
},
|
||||
{
|
||||
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">
|
||||
{item.invoice?.id && (
|
||||
<Link to={Paths.perfomaInvoice.detail + item.invoice.id}>
|
||||
<Eye size={20} color="#8C90A3" />
|
||||
</Link>
|
||||
)}
|
||||
{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="mt-5">
|
||||
<h1 className="text-lg font-light">پرداختها</h1>
|
||||
|
||||
<div className="mt-8">
|
||||
<Tabs
|
||||
items={[
|
||||
{ label: 'همه', value: '' },
|
||||
{ label: 'در انتظار', value: PaymentStatusEnum.Pending },
|
||||
{ label: 'پرداخت شده', value: PaymentStatusEnum.Paid },
|
||||
{ label: 'ناموفق', value: PaymentStatusEnum.Failed },
|
||||
{ label: 'بازگشت داده شده', value: PaymentStatusEnum.Refunded },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tab) => {
|
||||
setActiveTab(tab as StatusTab);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Filters
|
||||
fields={[
|
||||
{
|
||||
type: 'input',
|
||||
name: 'search',
|
||||
placeholder: 'جستجو (شماره پیشفاکتور / مشتری)',
|
||||
},
|
||||
]}
|
||||
onChange={(values) => {
|
||||
setFilters(values);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<Table
|
||||
columns={columns}
|
||||
data={payments}
|
||||
isLoading={isLoading}
|
||||
noDataMessage="پرداختی یافت نشد."
|
||||
pagination={
|
||||
meta && meta.totalPages > 1
|
||||
? {
|
||||
currentPage: meta.page,
|
||||
totalPages: meta.totalPages,
|
||||
onPageChange: setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModalConfrim
|
||||
isOpen={!!confirmAction}
|
||||
close={() => setConfirmAction(null)}
|
||||
onConfrim={handleConfirmAction}
|
||||
isloading={isActionLoading}
|
||||
label={getConfirmLabel()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentsList;
|
||||
@@ -0,0 +1,12 @@
|
||||
export enum PaymentStatusEnum {
|
||||
Pending = 'pending',
|
||||
Paid = 'paid',
|
||||
Failed = 'failed',
|
||||
Refunded = 'refunded',
|
||||
}
|
||||
|
||||
export enum PaymentMethodEnum {
|
||||
Online = 'Online',
|
||||
Cash = 'Cash',
|
||||
Credit = 'Credit',
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import * as api from '../service/PaymentService';
|
||||
import type { GetPaymentsParams } from '../types/Types';
|
||||
|
||||
export const useGetPayments = (params: GetPaymentsParams) => {
|
||||
return useQuery({
|
||||
queryKey: ['payments', params],
|
||||
queryFn: () => api.getPayments(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const useConfirmCashPayment = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (paymentId: string) => api.confirmCashPayment(paymentId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['payments'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDenyCashPayment = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (paymentId: string) => api.denyCashPayment(paymentId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['payments'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useVerifyOnlinePayment = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (token: string) => api.verifyOnlinePayment(token),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['payments'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import axios from '@/config/axios';
|
||||
import type { GetPaymentsParams, GetPaymentsResponseType } from '../types/Types';
|
||||
|
||||
export const getPayments = async (
|
||||
params: GetPaymentsParams
|
||||
): Promise<GetPaymentsResponseType> => {
|
||||
const { data } = await axios.get<GetPaymentsResponseType>('/admin/payments', {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const confirmCashPayment = async (paymentId: string) => {
|
||||
const { data } = await axios.post(`/admin/payments/${paymentId}/cash/verify`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const denyCashPayment = async (paymentId: string) => {
|
||||
const { data } = await axios.delete(`/admin/payments/${paymentId}/cash/deny`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const verifyOnlinePayment = async (token: string) => {
|
||||
const { data } = await axios.post(`/admin/payments/online/${token}/verify`);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { PaymentMethodEnum, PaymentStatusEnum } from '../enum/PaymentEnum';
|
||||
|
||||
export type PaymentUserType = {
|
||||
id: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
phone?: string;
|
||||
};
|
||||
|
||||
export type PaymentInvoiceType = {
|
||||
id: string;
|
||||
invoiceNumber: number;
|
||||
user?: PaymentUserType;
|
||||
};
|
||||
|
||||
export type PaymentType = {
|
||||
id: string;
|
||||
amount: number;
|
||||
method: PaymentMethodEnum;
|
||||
status: PaymentStatusEnum;
|
||||
gateway?: string | null;
|
||||
token?: string | null;
|
||||
transactionId?: string | null;
|
||||
description?: string | null;
|
||||
createdAt: string;
|
||||
paidAt?: string | null;
|
||||
failedAt?: string | null;
|
||||
invoice?: PaymentInvoiceType;
|
||||
};
|
||||
|
||||
export type GetPaymentsParams = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
statuses?: PaymentStatusEnum[];
|
||||
invoiceId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export type GetPaymentsResponseType = {
|
||||
statusCode: number;
|
||||
success: boolean;
|
||||
data: PaymentType[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
};
|
||||
@@ -56,6 +56,7 @@ import ConvertToOrders from "@/pages/order/ConvertToOrders";
|
||||
import OrderPrint from "@/pages/order/Print";
|
||||
import TicketList from "@/pages/ticket/TicketList";
|
||||
import TicketDetail from "@/pages/ticket/Detail";
|
||||
import PaymentsList from "@/pages/payment/List";
|
||||
|
||||
const MainRouter: FC = () => {
|
||||
return (
|
||||
@@ -71,6 +72,7 @@ const MainRouter: FC = () => {
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path={Paths.home} element={<Home />} />
|
||||
<Route path={Paths.perfomaInvoice.list} element={<ProformaInvoice />} />
|
||||
<Route path={Paths.payments.list} element={<PaymentsList />} />
|
||||
<Route path={Paths.perfomaInvoice.create} element={<CreateInvoice />} />
|
||||
<Route path={Paths.perfomaInvoice.detail + ":id"} element={<DetailPerfomaInvoice />} />
|
||||
<Route path={Paths.perfomaInvoice.update + ":id"} element={<UpdateInvoice />} />
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
People,
|
||||
Printer,
|
||||
Receipt21,
|
||||
MoneyRecive,
|
||||
ShieldSecurity,
|
||||
Teacher,
|
||||
User,
|
||||
@@ -90,6 +91,14 @@ const SideBar: FC = () => {
|
||||
activeName='view_invoices'
|
||||
/>
|
||||
|
||||
<SideBarItem
|
||||
icon={<MoneyRecive size={iconSizeSideBar} color="#4F5260" />}
|
||||
title={'پرداختها'}
|
||||
isActive={isActive('/payments')}
|
||||
link={Paths.payments.list}
|
||||
activeName='manage_payments'
|
||||
/>
|
||||
|
||||
<SideBarItem
|
||||
icon={<Element3 size={iconSizeSideBar} color="#4F5260" />}
|
||||
title={'سفارشات'}
|
||||
|
||||
Reference in New Issue
Block a user