This commit is contained in:
hamid zarghami
2026-07-04 12:50:02 +03:30
parent 23f47705bb
commit 6a6aa76b1e
17 changed files with 560 additions and 10 deletions
+2 -2
View File
@@ -66,13 +66,13 @@ server {
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
location ~ ^/(foods|orders|customers|schedule|payment_methods|discounts|announcements|reports|comments|roles|admins|shipment_methods|coupons|reviews|pagers|notifications)/ {
location ~ ^/(foods|orders|customers|schedule|payment_methods|discounts|announcements|reports|comments|roles|admins|shipment_methods|coupons|reviews|pagers|notifications|wallet)/ {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# Restaurant menu preview (same-origin iframe; strips X-Frame-Options from upstream)
location ~ ^/(?!auth|dashboard|setting|statistics|foods|orders|customers|schedule|payment_methods|discounts|announcements|reports|comments|roles|admins|shipment_methods|coupons|reviews|pagers|notifications|assets|icons|_next)([a-zA-Z0-9][a-zA-Z0-9_-]*)(/.*)?$ {
location ~ ^/(?!auth|dashboard|setting|statistics|foods|orders|customers|schedule|payment_methods|discounts|announcements|reports|comments|roles|admins|shipment_methods|coupons|reviews|pagers|notifications|wallet|assets|icons|_next)([a-zA-Z0-9][a-zA-Z0-9_-]*)(/.*)?$ {
proxy_pass https://dmenu.danakcorp.com/$1$2$is_args$args;
proxy_ssl_server_name on;
proxy_set_header Host dmenu.danakcorp.com;
+3
View File
@@ -96,4 +96,7 @@ export const Pages = {
statistics: {
list: "/statistics",
},
wallet: {
transactions: "/wallet/transactions",
},
};
+1
View File
@@ -23,6 +23,7 @@ export const MENU_PREVIEW_RESERVED_SEGMENTS = new Set([
"pagers",
"notifications",
"sliders",
"wallet",
"assets",
"menu-preview",
]);
+15
View File
@@ -620,6 +620,21 @@
"invoice_issued": "صورتحساب صادر شد",
"cancel": "انصراف",
"balance": "موجودی کیف پول",
"balance_after": "موجودی پس از تراکنش",
"type_credit": "واریز",
"type_debit": "برداشت",
"reason": "دلیل",
"reason_deposit": "شارژ کیف پول",
"reason_sms_send": "ارسال پیامک",
"transactions_tab": "تراکنش‌ها",
"charge_requests_tab": "درخواست‌های شارژ",
"invoice": "صورتحساب",
"view_invoice": "مشاهده صورتحساب",
"charge_status_pending": "در انتظار پرداخت",
"charge_status_paid": "پرداخت شده",
"charge_status_success": "پرداخت موفق",
"charge_status_failed": "ناموفق",
"charge_status_cancelled": "لغو شده",
"increese_wallet": "افزایش موجودی حساب اعتباری",
"online_pay": "پرداخت آنلاین",
"card_to_card": "کارت به کارت",
+88
View File
@@ -0,0 +1,88 @@
import Button from "@/components/Button";
import Tabs from "@/components/Tabs";
import { EmptyWallet, ReceiptItem } from "iconsax-react";
import { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
import { formatPrice } from "@/helpers/func";
import ChargeModal from "./components/ChargeModal";
import ChargeRequestsTab from "./components/ChargeRequestsTab";
import TransactionsTab from "./components/TransactionsTab";
import { WalletTabEnum } from "./enum/Enum";
import { useGetWalletBalance } from "./hooks/useWalletData";
import MoonLoader from "react-spinners/MoonLoader";
const WalletTransactions: FC = () => {
const { t } = useTranslation("global");
const [activeTab, setActiveTab] = useState<WalletTabEnum>(WalletTabEnum.TRANSACTIONS);
const [showChargeModal, setShowChargeModal] = useState(false);
const { data: balanceData, isLoading: isBalanceLoading } = useGetWalletBalance();
const balance = balanceData?.data?.balance ?? 0;
return (
<div className="mt-5">
<div className="flex justify-between items-center">
<h1 className="text-lg font-light">{t("transaction.transaction")}</h1>
<Button className="w-fit px-6" onClick={() => setShowChargeModal(true)}>
<div className="flex gap-2 items-center">
<EmptyWallet color="#fff" size={20} />
<span>{t("wallet.charge")}</span>
</div>
</Button>
</div>
<div className="mt-6 bg-[#F5F5F5] rounded-2xl px-6 py-4 flex items-center gap-3 w-fit">
<EmptyWallet size={22} color="black" />
<div>
<div className="text-xs text-description">{t("wallet.balance")}</div>
<div className="text-sm font-medium min-w-[100px]">
{isBalanceLoading ? (
<MoonLoader size={14} color="#000" />
) : (
formatPrice(balance)
)}
</div>
</div>
</div>
<div className="mt-8">
<Tabs
items={[
{
icon: (
<ReceiptItem
color={activeTab === WalletTabEnum.TRANSACTIONS ? "black" : "#8C90A3"}
size={24}
/>
),
label: t("wallet.transactions_tab"),
value: WalletTabEnum.TRANSACTIONS,
},
{
icon: (
<EmptyWallet
color={activeTab === WalletTabEnum.CHARGE_REQUESTS ? "black" : "#8C90A3"}
size={24}
/>
),
label: t("wallet.charge_requests_tab"),
value: WalletTabEnum.CHARGE_REQUESTS,
},
]}
onChange={(value) => setActiveTab(value as WalletTabEnum)}
active={activeTab}
/>
</div>
{activeTab === WalletTabEnum.TRANSACTIONS && <TransactionsTab />}
{activeTab === WalletTabEnum.CHARGE_REQUESTS && <ChargeRequestsTab />}
<ChargeModal
open={showChargeModal}
onClose={() => setShowChargeModal(false)}
/>
</div>
);
};
export default WalletTransactions;
@@ -0,0 +1,79 @@
import type { ColumnType } from "@/components/types/TableTypes";
import StatusWithText from "@/components/StatusWithText";
import { formatOptionalDate, formatPrice } from "@/helpers/func";
import type { TFunction } from "i18next";
import type { ChargeRequest } from "../types/Types";
const getStatusVariant = (status?: string | null): "success" | "error" | "warning" => {
if (!status) return "warning";
const normalized = status.toLowerCase();
if (["success", "successful", "paid", "completed", "approved"].includes(normalized)) {
return "success";
}
if (["failed", "unsuccessful", "rejected", "cancelled", "canceled"].includes(normalized)) {
return "error";
}
return "warning";
};
const getStatusLabel = (status: string | null | undefined, t: TFunction) => {
if (!status) return "-";
const key = `wallet.charge_status_${status.toLowerCase()}`;
const translated = t(key);
return translated !== key ? translated : status;
};
const openInvoice = (invoiceId: string) => {
const invoiceBaseUrl = import.meta.env.VITE_INVOICE_URL;
if (invoiceId && invoiceBaseUrl) {
window.open(`${invoiceBaseUrl}${invoiceId}`, "_blank");
}
};
export const getChargeRequestTableColumns = (
t: TFunction
): ColumnType<ChargeRequest>[] => {
return [
{
key: "amount",
title: t("transaction.amount"),
render: (item: ChargeRequest) => formatPrice(item.amount),
},
{
key: "status",
title: t("transaction.status"),
render: (item: ChargeRequest) => (
<StatusWithText
variant={getStatusVariant(item.status)}
text={getStatusLabel(item.status, t)}
/>
),
},
{
key: "invoiceId",
title: t("wallet.invoice"),
render: (item: ChargeRequest) =>
item.invoiceId ? (
<button
type="button"
onClick={() => openInvoice(item.invoiceId)}
className="text-primary text-xs hover:underline"
>
{t("wallet.view_invoice")}
</button>
) : (
"-"
),
},
{
key: "createdAt",
title: t("transaction.date"),
render: (item: ChargeRequest) => formatOptionalDate(item.createdAt),
},
];
};
@@ -0,0 +1,55 @@
import Filters from "@/components/Filters";
import Table from "@/components/Table";
import type { RowDataType } from "@/components/types/TableTypes";
import { type FC } from "react";
import { useTranslation } from "react-i18next";
import { getChargeRequestTableColumns } from "./ChargeRequestTableColumns";
import { useGetChargeRequests } from "../hooks/useWalletData";
import { useChargeRequestFilters } from "../hooks/useChargeRequestFilters";
import type { ChargeRequest } from "../types/Types";
const ChargeRequestsTab: FC = () => {
const { t } = useTranslation("global");
const {
filters,
currentPage,
apiParams,
handleFiltersChange,
handlePageChange,
limit,
} = useChargeRequestFilters();
const { data: chargeRequestsData, isLoading } = useGetChargeRequests(apiParams);
const chargeRequests = chargeRequestsData?.data || [];
const totalPages =
chargeRequestsData?.meta?.totalPages ||
Math.max(1, Math.ceil(chargeRequests.length / limit) || 1);
const columns = getChargeRequestTableColumns(t);
return (
<>
<div className="mt-8">
<Filters
fields={[]}
onChange={handleFiltersChange}
initialValues={filters}
searchField="search"
/>
</div>
<Table<ChargeRequest & RowDataType>
columns={columns}
data={chargeRequests as (ChargeRequest & RowDataType)[]}
isloading={isLoading}
pagination={{
currentPage,
totalPages,
onPageChange: handlePageChange,
}}
/>
</>
);
};
export default ChargeRequestsTab;
@@ -0,0 +1,60 @@
import type { ColumnType } from "@/components/types/TableTypes";
import StatusWithText from "@/components/StatusWithText";
import { formatOptionalDate, formatPrice } from "@/helpers/func";
import type { TFunction } from "i18next";
import type { WalletTransaction } from "../types/Types";
const getTypeLabel = (type: string, t: TFunction) => {
if (type === "credit") return t("wallet.type_credit");
if (type === "debit") return t("wallet.type_debit");
return type || "-";
};
const getTypeVariant = (type: string): "success" | "error" | "warning" => {
if (type === "credit") return "success";
if (type === "debit") return "error";
return "warning";
};
const getReasonLabel = (reason: string, t: TFunction) => {
const key = `wallet.reason_${reason}`;
const translated = t(key);
return translated !== key ? translated : reason || "-";
};
export const getTransactionTableColumns = (
t: TFunction
): ColumnType<WalletTransaction>[] => {
return [
{
key: "type",
title: t("transaction.transaction2"),
render: (item: WalletTransaction) => (
<StatusWithText
variant={getTypeVariant(item.type)}
text={getTypeLabel(item.type, t)}
/>
),
},
{
key: "amount",
title: t("transaction.amount"),
render: (item: WalletTransaction) => formatPrice(item.amount),
},
{
key: "balance",
title: t("wallet.balance_after"),
render: (item: WalletTransaction) => formatPrice(item.balance),
},
{
key: "reason",
title: t("wallet.reason"),
render: (item: WalletTransaction) => getReasonLabel(item.reason, t),
},
{
key: "createdAt",
title: t("transaction.date"),
render: (item: WalletTransaction) => formatOptionalDate(item.createdAt),
},
];
};
@@ -0,0 +1,55 @@
import Filters from "@/components/Filters";
import Table from "@/components/Table";
import type { RowDataType } from "@/components/types/TableTypes";
import { type FC } from "react";
import { useTranslation } from "react-i18next";
import { getTransactionTableColumns } from "./TransactionTableColumns";
import { useGetWalletTransactions } from "../hooks/useWalletData";
import { useWalletTransactionFilters } from "../hooks/useWalletTransactionFilters";
import type { WalletTransaction } from "../types/Types";
const TransactionsTab: FC = () => {
const { t } = useTranslation("global");
const {
filters,
currentPage,
apiParams,
handleFiltersChange,
handlePageChange,
limit,
} = useWalletTransactionFilters();
const { data: transactionsData, isLoading } = useGetWalletTransactions(apiParams);
const transactions = transactionsData?.data || [];
const totalPages =
transactionsData?.meta?.totalPages ||
Math.max(1, Math.ceil(transactions.length / limit) || 1);
const columns = getTransactionTableColumns(t);
return (
<>
<div className="mt-8">
<Filters
fields={[]}
onChange={handleFiltersChange}
initialValues={filters}
searchField="search"
/>
</div>
<Table<WalletTransaction & RowDataType>
columns={columns}
data={transactions as (WalletTransaction & RowDataType)[]}
isloading={isLoading}
pagination={{
currentPage,
totalPages,
onPageChange: handlePageChange,
}}
/>
</>
);
};
export default TransactionsTab;
@@ -1,6 +1,8 @@
import { type FC, useState } from "react";
import { EmptyWallet } from "iconsax-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Pages } from "@/config/Pages";
import { formatPrice } from "@/helpers/func";
import { useGetWalletBalance } from "../hooks/useWalletData";
import ChargeModal from "./ChargeModal";
@@ -8,6 +10,7 @@ import MoonLoader from "react-spinners/MoonLoader";
const WalletHeader: FC = () => {
const { t } = useTranslation("global");
const navigate = useNavigate();
const [showChargeModal, setShowChargeModal] = useState(false);
const { data, isLoading } = useGetWalletBalance();
@@ -16,6 +19,11 @@ const WalletHeader: FC = () => {
return (
<>
<div className="flex items-center gap-2 bg-[#F5F5F5] rounded-xl px-3 h-9">
<button
type="button"
onClick={() => navigate(Pages.wallet.transactions)}
className="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
>
<EmptyWallet size={18} color="black" />
<div className="text-xs whitespace-nowrap min-w-[80px]">
{isLoading ? (
@@ -24,6 +32,7 @@ const WalletHeader: FC = () => {
formatPrice(balance)
)}
</div>
</button>
<button
type="button"
onClick={() => setShowChargeModal(true)}
+4
View File
@@ -0,0 +1,4 @@
export enum WalletTabEnum {
TRANSACTIONS = "transactions",
CHARGE_REQUESTS = "charge_requests",
}
@@ -0,0 +1,43 @@
import { useMemo, useState } from "react";
import type { FilterValues } from "@/components/Filters";
import type { GetChargeRequestsParams } from "../types/Types";
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 10;
export const useChargeRequestFilters = () => {
const [filters, setFilters] = useState<FilterValues>({});
const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
const [limit] = useState(DEFAULT_LIMIT);
const apiParams = useMemo<GetChargeRequestsParams>(() => {
const params: GetChargeRequestsParams = {
page: currentPage,
limit,
};
if (filters.search) {
params.search = filters.search as string;
}
return params;
}, [filters, currentPage, limit]);
const handleFiltersChange = (newFilters: FilterValues) => {
setFilters(newFilters);
setCurrentPage(DEFAULT_PAGE);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
return {
filters,
currentPage,
apiParams,
handleFiltersChange,
handlePageChange,
limit,
};
};
+16
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as api from "../service/WalletService";
import type { GetChargeRequestsParams, GetWalletTransactionsParams } from "../types/Types";
export const useGetWalletBalance = () => {
return useQuery({
@@ -14,6 +15,21 @@ export const useCreateChargeRequest = () => {
mutationFn: api.createChargeRequest,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["wallet-balance"] });
queryClient.invalidateQueries({ queryKey: ["charge-requests"] });
},
});
};
export const useGetWalletTransactions = (params?: GetWalletTransactionsParams) => {
return useQuery({
queryKey: ["wallet-transactions", params],
queryFn: () => api.getWalletTransactions(params),
});
};
export const useGetChargeRequests = (params?: GetChargeRequestsParams) => {
return useQuery({
queryKey: ["charge-requests", params],
queryFn: () => api.getChargeRequests(params),
});
};
@@ -0,0 +1,43 @@
import { useMemo, useState } from "react";
import type { FilterValues } from "@/components/Filters";
import type { GetWalletTransactionsParams } from "../types/Types";
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 10;
export const useWalletTransactionFilters = () => {
const [filters, setFilters] = useState<FilterValues>({});
const [currentPage, setCurrentPage] = useState(DEFAULT_PAGE);
const [limit] = useState(DEFAULT_LIMIT);
const apiParams = useMemo<GetWalletTransactionsParams>(() => {
const params: GetWalletTransactionsParams = {
page: currentPage,
limit,
};
if (filters.search) {
params.search = filters.search as string;
}
return params;
}, [filters, currentPage, limit]);
const handleFiltersChange = (newFilters: FilterValues) => {
setFilters(newFilters);
setCurrentPage(DEFAULT_PAGE);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
return {
filters,
currentPage,
apiParams,
handleFiltersChange,
handlePageChange,
limit,
};
};
+24
View File
@@ -2,7 +2,11 @@ import axios from "@/config/axios";
import type {
ChargeRequestResponse,
ChargeRequestType,
ChargeRequestsResponse,
GetChargeRequestsParams,
GetWalletTransactionsParams,
WalletBalanceResponse,
WalletTransactionsResponse,
} from "../types/Types";
export const getWalletBalance = async (): Promise<WalletBalanceResponse> => {
@@ -21,3 +25,23 @@ export const createChargeRequest = async (
);
return data;
};
export const getWalletTransactions = async (
params?: GetWalletTransactionsParams
): Promise<WalletTransactionsResponse> => {
const { data } = await axios.get<WalletTransactionsResponse>(
"/admin/restaurants/wallet/transactions",
{ params }
);
return data;
};
export const getChargeRequests = async (
params?: GetChargeRequestsParams
): Promise<ChargeRequestsResponse> => {
const { data } = await axios.get<ChargeRequestsResponse>(
"/admin/restaurants/charge-requests",
{ params }
);
return data;
};
+52
View File
@@ -18,3 +18,55 @@ export type ChargeRequestResult = {
};
export type ChargeRequestResponse = IResponse<ChargeRequestResult>;
export type ChargeRequest = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
restaurant: string;
amount: number;
invoiceId: string;
status: string;
};
export type GetChargeRequestsParams = {
page?: number;
limit?: number;
search?: string;
};
export type ChargeRequestsResponse = IResponse<ChargeRequest[]> & {
meta?: PaginationMeta;
};
export type WalletTransactionType = "credit" | "debit";
export type WalletTransaction = {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
restaurant: string;
amount: number;
balance: number;
type: WalletTransactionType | string;
reason: string;
};
export type PaginationMeta = {
total: number;
page: number;
limit: number;
totalPages: number;
};
export type GetWalletTransactionsParams = {
page?: number;
limit?: number;
search?: string;
};
export type WalletTransactionsResponse = IResponse<WalletTransaction[]> & {
meta?: PaginationMeta;
};
+3
View File
@@ -55,6 +55,7 @@ import CreateUserGroup from '@/pages/customers/groups/Create'
import UpdateUserGroup from '@/pages/customers/groups/Update'
import CampaignsList from '@/pages/customers/campaigns/List'
import CreateCampaign from '@/pages/customers/campaigns/Create'
import WalletTransactions from '@/pages/wallet/Transactions'
const MainRouter: FC = () => {
const { hasSubMenu } = useSharedStore()
@@ -132,6 +133,8 @@ const MainRouter: FC = () => {
<Route path={Pages.reports.list} element={<ReportsList />} />
<Route path={Pages.statistics.list} element={<Statistics />} />
<Route path={Pages.wallet.transactions} element={<WalletTransactions />} />
</Routes>
</div>
</div>