add payment page

This commit is contained in:
2026-05-24 10:17:16 +03:30
parent 92c0f96358
commit 28295dcc58
9 changed files with 440 additions and 0 deletions
+8
View File
@@ -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
+3
View File
@@ -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: {
+289
View File
@@ -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;
+12
View File
@@ -0,0 +1,12 @@
export enum PaymentStatusEnum {
Pending = 'pending',
Paid = 'paid',
Failed = 'failed',
Refunded = 'refunded',
}
export enum PaymentMethodEnum {
Online = 'Online',
Cash = 'Cash',
Credit = 'Credit',
}
+40
View File
@@ -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;
};
+51
View File
@@ -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;
};
};
+2
View File
@@ -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 />} />
+9
View File
@@ -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={'سفارشات'}