add payment page
This commit is contained in:
@@ -10,6 +10,14 @@ WORKDIR /build
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY . ./
|
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
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:stable-alpine AS production-stage
|
FROM nginx:stable-alpine AS production-stage
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ export const Paths = {
|
|||||||
detail: '/invoice/',
|
detail: '/invoice/',
|
||||||
update: '/invoice/update/',
|
update: '/invoice/update/',
|
||||||
},
|
},
|
||||||
|
payments: {
|
||||||
|
list: '/payments',
|
||||||
|
},
|
||||||
newOrder: '/new-order',
|
newOrder: '/new-order',
|
||||||
orderDetails: '/order/',
|
orderDetails: '/order/',
|
||||||
tickets: {
|
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 OrderPrint from "@/pages/order/Print";
|
||||||
import TicketList from "@/pages/ticket/TicketList";
|
import TicketList from "@/pages/ticket/TicketList";
|
||||||
import TicketDetail from "@/pages/ticket/Detail";
|
import TicketDetail from "@/pages/ticket/Detail";
|
||||||
|
import PaymentsList from "@/pages/payment/List";
|
||||||
|
|
||||||
const MainRouter: FC = () => {
|
const MainRouter: FC = () => {
|
||||||
return (
|
return (
|
||||||
@@ -71,6 +72,7 @@ const MainRouter: FC = () => {
|
|||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path={Paths.home} element={<Home />} />
|
<Route path={Paths.home} element={<Home />} />
|
||||||
<Route path={Paths.perfomaInvoice.list} element={<ProformaInvoice />} />
|
<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.create} element={<CreateInvoice />} />
|
||||||
<Route path={Paths.perfomaInvoice.detail + ":id"} element={<DetailPerfomaInvoice />} />
|
<Route path={Paths.perfomaInvoice.detail + ":id"} element={<DetailPerfomaInvoice />} />
|
||||||
<Route path={Paths.perfomaInvoice.update + ":id"} element={<UpdateInvoice />} />
|
<Route path={Paths.perfomaInvoice.update + ":id"} element={<UpdateInvoice />} />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
People,
|
People,
|
||||||
Printer,
|
Printer,
|
||||||
Receipt21,
|
Receipt21,
|
||||||
|
MoneyRecive,
|
||||||
ShieldSecurity,
|
ShieldSecurity,
|
||||||
Teacher,
|
Teacher,
|
||||||
User,
|
User,
|
||||||
@@ -90,6 +91,14 @@ const SideBar: FC = () => {
|
|||||||
activeName='view_invoices'
|
activeName='view_invoices'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SideBarItem
|
||||||
|
icon={<MoneyRecive size={iconSizeSideBar} color="#4F5260" />}
|
||||||
|
title={'پرداختها'}
|
||||||
|
isActive={isActive('/payments')}
|
||||||
|
link={Paths.payments.list}
|
||||||
|
activeName='manage_payments'
|
||||||
|
/>
|
||||||
|
|
||||||
<SideBarItem
|
<SideBarItem
|
||||||
icon={<Element3 size={iconSizeSideBar} color="#4F5260" />}
|
icon={<Element3 size={iconSizeSideBar} color="#4F5260" />}
|
||||||
title={'سفارشات'}
|
title={'سفارشات'}
|
||||||
|
|||||||
Reference in New Issue
Block a user