Compare commits

..

No commits in common. "56a891e6685336979a8c0c61f84ec193681472c0" and "3690a8c1f62cdfe63c1b85a8a39180a466b41c9a" have entirely different histories.

44 changed files with 162 additions and 3050 deletions

View File

@ -76,12 +76,8 @@ const IPGListPage = lazy(() => import('./pages/payment-ipg/ipg-list/IPGListPage'
// Payment Card Page
const CardFormPage = lazy(() => import('./pages/payment-card/card-form/CardFormPage'));
// Wallet Pages
// Wallet Page
const WalletListPage = lazy(() => import('./pages/wallet/wallet-list/WalletListPage'));
const WalletCreditPage = lazy(() => import('./pages/wallet/wallet-credit/WalletCreditPage'));
// System Settings Page
const SystemSettingsPage = lazy(() => import('./pages/system-settings/SystemSettingsPage'));
// Reports Pages
const DiscountUsageReportPage = lazy(() => import('./pages/reports/discount-statistics/discount-usage-report/DiscountUsageReportPage'));
@ -89,21 +85,6 @@ const CustomerDiscountUsagePage = lazy(() => import('./pages/reports/discount-st
const PaymentMethodsReportPage = lazy(() => import('./pages/reports/payment-statistics/payment-methods-report/PaymentMethodsReportPage'));
const ShipmentsByMethodReportPage = lazy(() => import('./pages/reports/shipment-statistics/shipments-by-method-report/ShipmentsByMethodReportPage'));
// Sales Summary Report Page
const SalesSummaryReportPage = lazy(() => import('./pages/reports/sales-summary/sales-summary-report/SalesSummaryReportPage'));
// Profit Loss Report Page
const ProfitLossReportPage = lazy(() => import('./pages/reports/profit-loss/profit-loss-report/ProfitLossReportPage'));
// Inventory Value Report Page
const InventoryValueReportPage = lazy(() => import('./pages/reports/inventory-value/inventory-value-report/InventoryValueReportPage'));
// Variant Comparison Report Page
const VariantComparisonReportPage = lazy(() => import('./pages/reports/variant-comparison/variant-comparison-report/VariantComparisonReportPage'));
// Admin Notifications Page
const AdminNotificationsListPage = lazy(() => import('./pages/admin-notifications/notifications-list/AdminNotificationsListPage'));
// Product Comments Page
const ProductCommentsListPage = lazy(() => import('./pages/products/comments/comments-list/ProductCommentsListPage'));
@ -204,25 +185,14 @@ const AppRoutes = () => {
{/* Payment Card Route */}
<Route path="payment-card" element={<CardFormPage />} />
{/* Wallet Routes */}
{/* Wallet Route */}
<Route path="wallet" element={<WalletListPage />} />
<Route path="wallet/credit" element={<WalletCreditPage />} />
{/* System Settings Route */}
<Route path="system-settings" element={<SystemSettingsPage />} />
{/* Reports Routes */}
<Route path="reports/discount-usage" element={<DiscountUsageReportPage />} />
<Route path="reports/customer-discount-usage" element={<CustomerDiscountUsagePage />} />
<Route path="reports/payment-methods" element={<PaymentMethodsReportPage />} />
<Route path="reports/shipments-by-method" element={<ShipmentsByMethodReportPage />} />
<Route path="reports/sales-summary" element={<SalesSummaryReportPage />} />
<Route path="reports/profit-loss" element={<ProfitLossReportPage />} />
<Route path="reports/inventory-value" element={<InventoryValueReportPage />} />
<Route path="reports/variant-comparison" element={<VariantComparisonReportPage />} />
{/* Admin Notifications Route */}
<Route path="admin-notifications" element={<AdminNotificationsListPage />} />
</Route>
</Routes>
);

View File

@ -1,4 +1,4 @@
import { Menu, Sun, Moon, User, LogOut } from 'lucide-react';
import { Menu, Sun, Moon, Bell, User, LogOut } from 'lucide-react';
import { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { useTheme } from '../../contexts/ThemeContext';
@ -38,6 +38,11 @@ export const Header = ({ onMenuClick }: HeaderProps) => {
)}
</button>
<button className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors relative">
<Bell className="h-5 w-5 text-gray-600 dark:text-gray-400" />
<span className="absolute top-0 left-0 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}

View File

@ -22,11 +22,7 @@ import {
Wallet,
BarChart3,
FileText,
TrendingUp,
Bell,
DollarSign,
TrendingDown,
Warehouse
TrendingUp
} from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import { PermissionWrapper } from '../common/PermissionWrapper';
@ -63,9 +59,21 @@ const menuItems: MenuItem[] = [
path: '/discount-codes',
},
{
title: 'اعلانات ادمین',
icon: Bell,
path: '/admin-notifications',
title: 'تیکت‌ها',
icon: MessageSquare,
children: [
{
title: 'لیست تیکت‌ها',
icon: MessageSquare,
path: '/tickets',
exact: true,
},
{
title: 'تنظیمات تیکت',
icon: Sliders,
path: '/tickets/config',
},
]
},
{
title: 'پیام‌های تماس با ما',
@ -122,26 +130,6 @@ const menuItems: MenuItem[] = [
icon: Truck,
path: '/reports/shipments-by-method',
},
{
title: 'گزارش خلاصه فروش',
icon: DollarSign,
path: '/reports/sales-summary',
},
{
title: 'گزارش سود و زیان',
icon: TrendingDown,
path: '/reports/profit-loss',
},
{
title: 'گزارش ارزش موجودی',
icon: Warehouse,
path: '/reports/inventory-value',
},
{
title: 'گزارش مقایسه Variant',
icon: TrendingUp,
path: '/reports/variant-comparison',
},
]
},
{
@ -190,17 +178,6 @@ const menuItems: MenuItem[] = [
title: 'مدیریت کیف پول',
icon: Wallet,
path: '/wallet',
exact: true,
},
{
title: 'شارژ کیف پول',
icon: Wallet,
path: '/wallet/credit',
},
{
title: 'تنظیمات سیستم',
icon: Settings,
path: '/system-settings',
},
]
}
@ -223,7 +200,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
// Auto-expand menu items based on current route
React.useEffect(() => {
const currentPath = location.pathname;
setExpandedItems(prev => {
const itemsToExpand: string[] = [];
@ -235,14 +212,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
if (child.exact) {
return currentPath === child.path;
}
// For non-exact paths, check if current path starts with child path
// but also ensure it's not a partial match (e.g., /wallet should not match /wallet/credit)
if (currentPath === child.path) {
return true;
}
// Only match if current path starts with child path AND has a slash after it
// This prevents /wallet from matching /wallet/credit
return currentPath.startsWith(child.path + '/');
return currentPath.startsWith(child.path);
}
return false;
});
@ -377,13 +347,13 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
</SectionTitle>
</div>
{/* Navigation - scrollable */}
<nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto overflow-x-hidden sidebar-nav min-h-0">
{/* Navigation */}
<nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto min-h-0">
{menuItems.map(item => renderMenuItem(item))}
</nav>
{/* User Info - fixed at bottom */}
<div className="border-t border-gray-200 dark:border-gray-700 p-4 flex-shrink-0 bg-white dark:bg-gray-800">
{/* User Info */}
<div className="border-t border-gray-200 dark:border-gray-700 p-4 flex-shrink-0">
<div className="flex items-center space-x-3 space-x-reverse">
<div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
<span className="text-sm font-medium text-white">

View File

@ -101,8 +101,6 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
const [feePercentageDisplay, setFeePercentageDisplay] = useState(variant?.fee_percentage?.toString() || '');
const [profitPercentageDisplay, setProfitPercentageDisplay] = useState(variant?.profit_percentage?.toString() || '');
const [taxPercentageDisplay, setTaxPercentageDisplay] = useState(variant?.tax_percentage?.toString() || '');
const [goldPricePerGramDisplay, setGoldPricePerGramDisplay] = useState(variant?.gold_price_per_gram?.toString() || '');
const [factoryFeePercentageDisplay, setFactoryFeePercentageDisplay] = useState(variant?.factory_fee_percentage?.toString() || '');
const { mutateAsync: uploadFile } = useFileUpload();
const { mutate: deleteFile } = useFileDelete();
@ -126,17 +124,11 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
if (variant?.tax_percentage !== undefined) {
setTaxPercentageDisplay(variant.tax_percentage.toString());
}
if (variant?.gold_price_per_gram !== undefined) {
setGoldPricePerGramDisplay(variant.gold_price_per_gram.toString());
}
if (variant?.factory_fee_percentage !== undefined) {
setFactoryFeePercentageDisplay(variant.factory_fee_percentage.toString());
}
// Load variant attribute value if exists
if (variantAttributeName && variant?.attributes && variant.attributes[variantAttributeName]) {
setVariantAttributeValue(variant.attributes[variantAttributeName].toString());
}
}, [variant?.weight, variant?.fee_percentage, variant?.profit_percentage, variant?.tax_percentage, variant?.gold_price_per_gram, variant?.factory_fee_percentage, variant?.attributes, variantAttributeName]);
}, [variant?.weight, variant?.fee_percentage, variant?.profit_percentage, variant?.tax_percentage, variant?.attributes, variantAttributeName]);
const handleInputChange = (field: keyof ProductVariantFormData, value: any) => {
if (typeof value === 'string') {
@ -317,50 +309,6 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
placeholder="مثال: ۱۲۰۰.۵"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
قیمت هر گرم طلا (تومان)
</label>
<input
type="text"
inputMode="decimal"
value={goldPricePerGramDisplay}
onChange={(e) => {
const converted = persianToEnglish(e.target.value);
setGoldPricePerGramDisplay(converted);
}}
onBlur={(e) => {
const converted = persianToEnglish(e.target.value);
const numValue = parseFloat(converted) || undefined;
handleInputChange('gold_price_per_gram', numValue);
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
placeholder="مثال: ۸۵۵۰۸۱۶"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
درصد اجرت کارخانه
</label>
<input
type="text"
inputMode="decimal"
value={factoryFeePercentageDisplay}
onChange={(e) => {
const converted = persianToEnglish(e.target.value);
setFactoryFeePercentageDisplay(converted);
}}
onBlur={(e) => {
const converted = persianToEnglish(e.target.value);
const numValue = parseFloat(converted) || undefined;
handleInputChange('factory_fee_percentage', numValue);
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
placeholder="مثال: ۲"
/>
</div>
</div>

View File

@ -160,27 +160,9 @@ export const API_ROUTES = {
SALES_GROWTH_REPORT: "reports/sales/growth",
USER_REGISTRATION_GROWTH_REPORT: "reports/user-registration/growth",
SALES_BY_CATEGORY_REPORT: "reports/sales/by-category",
SALES_SUMMARY_REPORT: "reports/sales/summary",
PROFIT_LOSS_REPORT: "reports/profit-loss",
INVENTORY_VALUE_REPORT: "reports/inventory/value",
VARIANT_COMPARISON_REPORT: "reports/variants/comparison",
// Product Comments APIs
GET_PRODUCT_COMMENTS: "products/comments",
UPDATE_COMMENT_STATUS: (commentId: string) => `products/comments/${commentId}/status`,
DELETE_COMMENT: (commentId: string) => `products/comments/${commentId}`,
// Admin Notifications APIs
GET_ADMIN_NOTIFICATIONS: "notifications",
GET_ADMIN_NOTIFICATIONS_UNREAD: "notifications/unread",
GET_ADMIN_NOTIFICATIONS_COUNT: "notifications/count",
MARK_NOTIFICATION_READ: (id: string) => `notifications/${id}/read`,
MARK_ALL_NOTIFICATIONS_READ: "notifications/read-all",
// System Settings APIs
GET_AUTO_VERIFY_SETTING: "settings/auto-verify-new-users",
UPDATE_AUTO_VERIFY_SETTING: "settings/auto-verify-new-users",
// Wallet Credit APIs
WALLET_CREDIT: "wallet/credit",
};

View File

@ -145,39 +145,4 @@
min-height: 180px;
}
}
/* Sidebar scrollbar styles */
.sidebar-nav {
scrollbar-width: thin;
scrollbar-color: #d1d5db transparent;
}
.sidebar-nav::-webkit-scrollbar {
width: 6px;
}
.sidebar-nav::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-nav::-webkit-scrollbar-thumb {
background-color: #d1d5db;
border-radius: 3px;
}
.sidebar-nav::-webkit-scrollbar-thumb:hover {
background-color: #9ca3af;
}
.dark .sidebar-nav {
scrollbar-color: #4b5563 transparent;
}
.dark .sidebar-nav::-webkit-scrollbar-thumb {
background-color: #4b5563;
}
.dark .sidebar-nav::-webkit-scrollbar-thumb:hover {
background-color: #6b7280;
}
}

View File

@ -1,6 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { FileText, Download, TrendingUp, Users, ShoppingBag, DollarSign, Warehouse, TrendingDown, CreditCard, BarChart3 } from 'lucide-react';
import { FileText, Download, TrendingUp, Users, ShoppingBag, DollarSign } from 'lucide-react';
import { Button } from '../components/ui/Button';
import { BarChart } from '../components/charts/BarChart';
import { lazy, Suspense } from 'react';
@ -8,7 +7,6 @@ import { lazy, Suspense } from 'react';
const LineChart = lazy(() => import('../components/charts/LineChart').then(module => ({ default: module.LineChart })));
export const Reports = () => {
const navigate = useNavigate();
const [selectedPeriod, setSelectedPeriod] = useState('month');
const salesData = [
@ -29,70 +27,38 @@ export const Reports = () => {
{ name: 'شهریور', value: 320 },
];
const reportPages = [
const reports = [
{
id: 1,
title: 'گزارش خلاصه فروش',
description: 'گزارش جامع فروش محصولات با تفکیک و آمار کامل',
icon: DollarSign,
path: '/reports/sales-summary',
type: 'فروش'
title: 'گزارش فروش ماهانه',
description: 'گزارش کامل فروش محصولات در ماه گذشته',
type: 'فروش',
date: '۱۴۰۲/۰۸/۳۰',
format: 'PDF'
},
{
id: 2,
title: 'گزارش ارزش موجودی',
description: 'ارزش کل موجودی به تومان بر اساس قیمت لحظه‌ای',
icon: Warehouse,
path: '/reports/inventory-value',
type: 'موجودی'
title: 'گزارش کاربران جدید',
description: 'آمار کاربران جدید عضو شده در سیستم',
type: 'کاربران',
date: '۱۴۰۲/۰۸/۲۹',
format: 'Excel'
},
{
id: 3,
title: 'گزارش روش‌های پرداخت',
description: 'آمار و گزارش روش‌های پرداخت استفاده شده',
icon: CreditCard,
path: '/reports/payment-methods',
type: 'پرداخت'
title: 'گزارش موجودی انبار',
description: 'وضعیت موجودی محصولات در انبار',
type: 'انبار',
date: '۱۴۰۲/۰۸/۲۸',
format: 'PDF'
},
{
id: 4,
title: 'گزارش استفاده کاربر خاص از کدهای تخفیف',
description: 'گزارش استفاده یک کاربر خاص از کدهای تخفیف',
icon: Users,
path: '/reports/customer-discount-usage',
type: 'تخفیف'
},
{
id: 5,
title: 'گزارش مقایسه Variant',
description: 'مقایسه درصد اجرت کارخانه و درصد اجرت کاربر',
icon: TrendingUp,
path: '/reports/variant-comparison',
type: 'مقایسه'
},
{
id: 6,
title: 'گزارش سود و زیان',
description: 'گزارش سود یا زیان محقق شده',
icon: TrendingDown,
path: '/reports/profit-loss',
type: 'مالی'
},
{
id: 7,
title: 'گزارش کدهای تخفیف',
description: 'گزارش استفاده از کدهای تخفیف',
icon: BarChart3,
path: '/reports/discount-usage',
type: 'تخفیف'
},
{
id: 8,
title: 'گزارش ارسال‌ها',
description: 'گزارش ارسال‌ها بر اساس روش ارسال',
icon: ShoppingBag,
path: '/reports/shipments-by-method',
type: 'ارسال'
title: 'گزارش درآمد روزانه',
description: 'جزئیات درآمد حاصل از فروش در ۳۰ روز گذشته',
type: 'مالی',
date: '۱۴۰۲/۰۸/۲۷',
format: 'Excel'
}
];
@ -209,36 +175,50 @@ export const Reports = () => {
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
گزارشهای موجود
گزارشهای اخیر
</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{reportPages.map((report) => {
const IconComponent = report.icon;
return (
<div
key={report.id}
onClick={() => navigate(report.path)}
className="flex items-start p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
>
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg ml-4 flex-shrink-0">
<IconComponent className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<div className="space-y-4">
{reports.map((report) => (
<div
key={report.id}
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg ml-4">
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1">
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
<div>
<h4 className="font-medium text-gray-900 dark:text-gray-100">
{report.title}
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
{report.description}
</p>
<span className="inline-block mt-2 text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
{report.type}
</span>
<div className="flex items-center mt-1 space-x-4">
<span className="text-xs text-gray-500 dark:text-gray-500">
نوع: {report.type}
</span>
<span className="text-xs text-gray-500 dark:text-gray-500">
تاریخ: {report.date}
</span>
<span className="text-xs text-gray-500 dark:text-gray-500">
فرمت: {report.format}
</span>
</div>
</div>
</div>
);
})}
<Button
size="sm"
variant="secondary"
onClick={() => handleDownloadReport(report.id)}
>
<Download className="h-4 w-4 ml-2" />
دانلود
</Button>
</div>
))}
</div>
</div>
</div>

View File

@ -1,58 +0,0 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/utils/query-key";
import {
getAdminNotifications,
getAdminNotificationsUnread,
getAdminNotificationsCount,
markNotificationRead,
markAllNotificationsRead,
} from "./_requests";
import { AdminNotificationsFilters } from "./_models";
export const useAdminNotifications = (filters?: AdminNotificationsFilters) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS, filters],
queryFn: () => getAdminNotifications(filters),
});
};
export const useAdminNotificationsUnread = (limit?: number) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS_UNREAD, limit],
queryFn: () => getAdminNotificationsUnread(limit),
});
};
export const useAdminNotificationsCount = () => {
return useQuery({
queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS_COUNT],
queryFn: () => getAdminNotificationsCount(),
refetchInterval: 30000,
});
};
export const useMarkNotificationRead = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (notificationId: number) => markNotificationRead(notificationId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS_UNREAD] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS_COUNT] });
},
});
};
export const useMarkAllNotificationsRead = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => markAllNotificationsRead(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS_UNREAD] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS_COUNT] });
},
});
};

View File

@ -1,44 +0,0 @@
export interface AdminNotification {
id: number;
admin_user_id: number;
title: string;
message: string;
metadata?: {
alert_type?: string;
error_msg?: string;
event_id?: string;
invoice_id?: string;
payment_id?: string;
retry_count?: string;
[key: string]: any;
};
is_read: boolean;
created_at: string;
updated_at: string;
}
export interface AdminNotificationsResponse {
notifications: AdminNotification[];
unread_count: number;
offset: number;
limit: number;
}
export interface AdminNotificationsUnreadResponse {
notifications: AdminNotification[];
count: number;
}
export interface AdminNotificationsCountResponse {
unread_count: number;
}
export interface MarkNotificationReadResponse {
success: boolean;
message: string;
}
export interface AdminNotificationsFilters {
offset?: number;
limit?: number;
}

View File

@ -1,59 +0,0 @@
import { httpGetRequest, httpPutRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import {
AdminNotificationsResponse,
AdminNotificationsUnreadResponse,
AdminNotificationsCountResponse,
MarkNotificationReadResponse,
AdminNotificationsFilters,
} from "./_models";
export const getAdminNotifications = async (
filters?: AdminNotificationsFilters
): Promise<AdminNotificationsResponse> => {
const queryParams: Record<string, string | number> = {};
if (filters?.offset !== undefined) queryParams.offset = filters.offset;
if (filters?.limit !== undefined) queryParams.limit = filters.limit;
const response = await httpGetRequest<AdminNotificationsResponse>(
APIUrlGenerator(API_ROUTES.GET_ADMIN_NOTIFICATIONS, queryParams)
);
return response.data;
};
export const getAdminNotificationsUnread = async (
limit?: number
): Promise<AdminNotificationsUnreadResponse> => {
const queryParams: Record<string, number> = {};
if (limit !== undefined) queryParams.limit = limit;
const response = await httpGetRequest<AdminNotificationsUnreadResponse>(
APIUrlGenerator(API_ROUTES.GET_ADMIN_NOTIFICATIONS_UNREAD, queryParams)
);
return response.data;
};
export const getAdminNotificationsCount = async (): Promise<AdminNotificationsCountResponse> => {
const response = await httpGetRequest<AdminNotificationsCountResponse>(
APIUrlGenerator(API_ROUTES.GET_ADMIN_NOTIFICATIONS_COUNT)
);
return response.data;
};
export const markNotificationRead = async (
notificationId: number
): Promise<MarkNotificationReadResponse> => {
const response = await httpPutRequest<MarkNotificationReadResponse>(
APIUrlGenerator(API_ROUTES.MARK_NOTIFICATION_READ(notificationId.toString()))
);
return response.data;
};
export const markAllNotificationsRead = async (): Promise<MarkNotificationReadResponse> => {
const response = await httpPutRequest<MarkNotificationReadResponse>(
APIUrlGenerator(API_ROUTES.MARK_ALL_NOTIFICATIONS_READ)
);
return response.data;
};

View File

@ -1,251 +0,0 @@
import { useState } from 'react';
import { Bell, BellOff, Check, CheckCheck, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Pagination } from '@/components/ui/Pagination';
import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { useAdminNotifications, useMarkNotificationRead, useMarkAllNotificationsRead } from '../core/_hooks';
import { AdminNotification } from '../core/_models';
import { formatDateTime } from '@/utils/formatters';
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
import { toast } from 'react-hot-toast';
const AdminNotificationsListPage = () => {
const [filters, setFilters] = useState({ offset: 0, limit: 20 });
const [filterType, setFilterType] = useState<'all' | 'unread'>('all');
const { data, isLoading, error } = useAdminNotifications(filters);
const { mutate: markRead, isPending: isMarkingRead } = useMarkNotificationRead();
const { mutate: markAllRead, isPending: isMarkingAllRead } = useMarkAllNotificationsRead();
const notifications = data?.notifications || [];
const unreadCount = data?.unread_count || 0;
const totalPages = Math.ceil((data?.notifications?.length || 0) / filters.limit);
const filteredNotifications = filterType === 'unread'
? notifications.filter(n => !n.is_read)
: notifications;
const handlePageChange = (page: number) => {
setFilters(prev => ({
...prev,
offset: (page - 1) * prev.limit,
}));
};
const handleMarkAsRead = (notificationId: number) => {
markRead(notificationId, {
onSuccess: () => {
toast.success('نوتیفیکیشن به عنوان خوانده شده علامت‌گذاری شد');
},
onError: () => {
toast.error('خطا در علامت‌گذاری نوتیفیکیشن');
},
});
};
const handleMarkAllAsRead = () => {
markAllRead(undefined, {
onSuccess: () => {
toast.success('همه نوتیفیکیشن‌ها به عنوان خوانده شده علامت‌گذاری شدند');
},
onError: () => {
toast.error('خطا در علامت‌گذاری نوتیفیکیشن‌ها');
},
});
};
const getNotificationIcon = (notification: AdminNotification) => {
if (notification.metadata?.alert_type === 'failed_order_creation') {
return <AlertCircle className="h-5 w-5 text-red-600" />;
}
return <Bell className="h-5 w-5 text-blue-600" />;
};
if (isLoading) {
return (
<PageContainer>
<ReportSkeleton />
</PageContainer>
);
}
if (error) {
return (
<PageContainer>
<div className="text-center py-12">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<p className="text-gray-500 dark:text-gray-400">خطا در بارگذاری نوتیفیکیشنها</p>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<div className="flex items-center justify-between mb-6">
<PageTitle>اعلانات ادمین</PageTitle>
<div className="flex items-center gap-4">
{unreadCount > 0 && (
<Button
variant="secondary"
onClick={handleMarkAllAsRead}
disabled={isMarkingAllRead}
>
<CheckCheck className="h-4 w-4 ml-2" />
همه را خوانده شده علامت بزن
</Button>
)}
</div>
</div>
<div className="mb-6 grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<Bell className="h-8 w-8 text-blue-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل اعلانات</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{notifications.length}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<BellOff className="h-8 w-8 text-red-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">خوانده نشده</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{unreadCount}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
<div className="flex items-center">
<Check className="h-8 w-8 text-green-600" />
<div className="mr-3">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">خوانده شده</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{notifications.length - unreadCount}
</p>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
لیست اعلانات
</h3>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value as 'all' | 'unread')}
className="input min-w-[150px]"
>
<option value="all">همه اعلانات</option>
<option value="unread">خوانده نشده</option>
</select>
</div>
<div className="p-6">
<div className="space-y-4">
{filteredNotifications.length === 0 ? (
<div className="text-center py-12">
<Bell className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500 dark:text-gray-400">هیچ اعلانی یافت نشد</p>
</div>
) : (
filteredNotifications.map((notification) => (
<div
key={notification.id}
className={`p-4 border-r-4 ${
notification.is_read
? 'border-r-gray-300 bg-gray-50 dark:bg-gray-700'
: 'border-r-blue-500 bg-white dark:bg-gray-800'
} border border-gray-200 dark:border-gray-600 rounded-lg shadow-sm hover:shadow-md transition-shadow`}
>
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3 flex-1">
<div className="flex-shrink-0 mt-1">
{getNotificationIcon(notification)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<h3
className={`text-sm font-medium ${
notification.is_read
? 'text-gray-600 dark:text-gray-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
{notification.title}
</h3>
{!notification.is_read && (
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
)}
</div>
<p
className={`mt-1 text-sm whitespace-pre-line ${
notification.is_read
? 'text-gray-500 dark:text-gray-500'
: 'text-gray-700 dark:text-gray-300'
}`}
>
{notification.message}
</p>
{notification.metadata && Object.keys(notification.metadata).length > 0 && (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-500">
{notification.metadata.invoice_id && (
<span className="ml-4">شماره فاکتور: {notification.metadata.invoice_id}</span>
)}
{notification.metadata.payment_id && (
<span className="ml-4">شماره پرداخت: {notification.metadata.payment_id}</span>
)}
</div>
)}
<div className="mt-2 text-xs text-gray-500 dark:text-gray-500">
{formatDateTime(notification.created_at)}
</div>
</div>
</div>
<div className="flex items-center space-x-2 mr-4">
{!notification.is_read && (
<Button
size="sm"
variant="secondary"
onClick={() => handleMarkAsRead(notification.id)}
disabled={isMarkingRead}
>
<Check className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
))
)}
</div>
{totalPages > 1 && (
<div className="mt-6">
<Pagination
currentPage={Math.floor(filters.offset / filters.limit) + 1}
totalPages={totalPages}
onPageChange={handlePageChange}
itemsPerPage={filters.limit}
totalItems={notifications.length}
/>
</div>
)}
</div>
</div>
</PageContainer>
);
};
export default AdminNotificationsListPage;

View File

@ -110,7 +110,6 @@ export interface Order {
national_code?: string;
verified: boolean;
avatar?: string;
is_deleted?: boolean;
};
payment_status?: PaymentStatus;
payments?: OrderPaymentRecord[];

View File

@ -179,13 +179,8 @@ const OrdersListPage = () => {
align: 'right',
render: (_val, row: any) => (
<div className="text-right">
<div className="font-medium flex items-center gap-2">
<div className="font-medium">
{(row.user?.first_name || row.customer?.first_name || 'نامشخص')} {(row.user?.last_name || row.customer?.last_name || '')}
{row.user?.is_deleted && (
<span className="px-2 py-0.5 text-xs bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 rounded">
حذف شده
</span>
)}
</div>
<div className="text-gray-500 dark:text-gray-400" dir="ltr" style={{ direction: 'ltr' }}>
{row.user?.phone_number ? englishToPersian(row.user.phone_number) : '-'}

View File

@ -71,7 +71,6 @@ export interface ProductFormData {
explorer_file_ids?: ProductImage[];
is_delete_latest_explorer_files?: boolean;
product_cover_image_id?: string;
sku?: string;
}
export interface ProductVariantFormData {
@ -87,8 +86,6 @@ export interface ProductVariantFormData {
attributes: Record<string, any>;
meta: Record<string, any>;
file_ids: ProductImage[];
gold_price_per_gram?: number;
factory_fee_percentage?: number;
}
export interface ProductFilters {
@ -114,7 +111,6 @@ export interface CreateProductRequest {
file_ids?: number[];
explorer_file_ids?: number[];
variants?: CreateVariantRequest[];
sku?: string;
}
export interface UpdateProductRequest {
@ -132,7 +128,6 @@ export interface UpdateProductRequest {
explorer_file_ids?: number[];
is_delete_latest_explorer_files?: boolean;
variants?: UpdateVariantRequest[];
sku?: string;
}
export interface CreateVariantRequest {
@ -148,8 +143,6 @@ export interface CreateVariantRequest {
attributes?: Record<string, any>;
meta?: Record<string, any>;
file_ids?: number[];
gold_price_per_gram?: number;
factory_fee_percentage?: number;
}
export interface UpdateVariantRequest {
@ -165,8 +158,6 @@ export interface UpdateVariantRequest {
attributes?: Record<string, any>;
meta?: Record<string, any>;
file_ids?: number[];
gold_price_per_gram?: number;
factory_fee_percentage?: number;
}
export interface ProductsResponse {

View File

@ -39,7 +39,6 @@ const productSchema = yup.object({
explorer_file_ids: yup.array().of(yup.object()).default([]),
is_delete_latest_explorer_files: yup.boolean().optional(),
product_cover_image_id: yup.string().optional(),
sku: yup.string().optional(),
});
const toPublicUrl = (img: any): ProductImage => {
@ -150,8 +149,7 @@ const ProductFormPage = () => {
variants: [],
explorer_file_ids: [],
is_delete_latest_explorer_files: false,
product_cover_image_id: undefined,
sku: undefined
product_cover_image_id: undefined
}
});
@ -189,9 +187,7 @@ const ProductFormPage = () => {
product_option_id: variant.product_option_id || undefined,
attributes: variant.attributes || {},
meta: variant.meta || {},
file_ids: (variant.file_ids && variant.file_ids.length > 0 ? variant.file_ids : (variant as any).files || []),
gold_price_per_gram: variant.gold_price_per_gram || undefined,
factory_fee_percentage: variant.factory_fee_percentage || undefined
file_ids: (variant.file_ids && variant.file_ids.length > 0 ? variant.file_ids : (variant as any).files || [])
}));
console.log('✅ Successfully processed variants:', formVariants.length);
@ -221,8 +217,7 @@ const ProductFormPage = () => {
product_option_id: product.product_option_id || undefined,
file_ids: (product.file_ids && product.file_ids.length > 0 ? product.file_ids : (product as any).files || []),
variants: formVariants,
product_cover_image_id: (product as any).product_cover_image_id ? (product as any).product_cover_image_id.toString() : undefined,
sku: product.sku || undefined
product_cover_image_id: (product as any).product_cover_image_id ? (product as any).product_cover_image_id.toString() : undefined
});
const initialImages = (product.file_ids && product.file_ids.length > 0 ? product.file_ids : (product as any).files || []);
const normalizedImages: ProductImage[] = (initialImages || []).map(toPublicUrl);
@ -340,7 +335,6 @@ const ProductFormPage = () => {
category_ids: convertedData.category_ids.length > 0 ? convertedData.category_ids : [],
product_option_id: convertedData.product_option_id || null,
file_ids: validImageIds,
sku: convertedData.sku || undefined,
};
const submitBaseData = {
@ -355,7 +349,7 @@ const ProductFormPage = () => {
if (isEdit && id) {
// برای update، variants باید شامل ID باشه
const updateVariants = data.variants?.map((variant: any) => ({
id: variant.id || 0,
id: variant.id || 0, // اگر ID نداره، 0 بذار (برای variant جدید)
enabled: variant.enabled,
fee_percentage: variant.fee_percentage,
profit_percentage: variant.profit_percentage,
@ -366,9 +360,7 @@ const ProductFormPage = () => {
weight: variant.weight,
file_ids: Array.isArray(variant.file_ids) ? variant.file_ids.map((file: any) => Number(typeof file === 'object' ? file.id : file)).filter((id: number) => !isNaN(id)) : [],
attributes: variant.attributes && convertedData.variant_attribute_name && variant.attributes[convertedData.variant_attribute_name] !== undefined ? { [convertedData.variant_attribute_name]: variant.attributes[convertedData.variant_attribute_name] } : {},
meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {},
gold_price_per_gram: variant.gold_price_per_gram || undefined,
factory_fee_percentage: variant.factory_fee_percentage || undefined
meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {}
})) || [];
const updatePayload = {
@ -398,9 +390,7 @@ const ProductFormPage = () => {
weight: variant.weight,
file_ids: Array.isArray(variant.file_ids) ? variant.file_ids.map((file: any) => Number(typeof file === 'object' ? file.id : file)).filter((id: number) => !isNaN(id)) : [],
attributes: variant.attributes && convertedData.variant_attribute_name && variant.attributes[convertedData.variant_attribute_name] !== undefined ? { [convertedData.variant_attribute_name]: variant.attributes[convertedData.variant_attribute_name] } : {},
meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {},
gold_price_per_gram: variant.gold_price_per_gram || undefined,
factory_fee_percentage: variant.factory_fee_percentage || undefined
meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {}
})) || [];
const createPayload: any = {
@ -529,13 +519,6 @@ const ProductFormPage = () => {
placeholder="مثال: آبکاری، رنگ، سایز..."
/>
<Input
label="SKU"
{...register('sku')}
error={errors.sku?.message}
placeholder="مثال: RING-001"
/>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
توضیحات

View File

@ -18,13 +18,12 @@ export const useDiscountUsageReport = (filters: DiscountUsageFilters) => {
};
export const useCustomerDiscountUsageReport = (
filters: CustomerDiscountUsageFilters & { _refetchKey?: number }
filters: CustomerDiscountUsageFilters
) => {
const { _refetchKey, ...cleanFilters } = filters;
return useQuery({
queryKey: [QUERY_KEYS.GET_CUSTOMER_DISCOUNT_USAGE_REPORT, cleanFilters, _refetchKey],
queryFn: () => getCustomerDiscountUsageReport(cleanFilters),
enabled: cleanFilters.user_id > 0 && cleanFilters.limit > 0,
queryKey: [QUERY_KEYS.GET_CUSTOMER_DISCOUNT_USAGE_REPORT, filters],
queryFn: () => getCustomerDiscountUsageReport(filters),
enabled: filters.user_id > 0 && filters.limit > 0,
});
};

View File

@ -1,4 +1,4 @@
import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import {
DiscountUsageFilters,
@ -10,19 +10,9 @@ import {
export const getDiscountUsageReport = async (
filters: DiscountUsageFilters
): Promise<DiscountUsageResponse> => {
const queryParams: Record<string, string | number | boolean> = {};
if (filters.date_range?.from) queryParams.from = filters.date_range.from;
if (filters.date_range?.to) queryParams.to = filters.date_range.to;
if (filters.discount_code) queryParams.discount_code = filters.discount_code;
if (filters.discount_id !== undefined) queryParams.discount_id = filters.discount_id;
if (filters.user_id !== undefined) queryParams.user_id = filters.user_id;
if (filters.group_by_code !== undefined) queryParams.group_by_code = filters.group_by_code;
if (filters.limit !== undefined) queryParams.limit = filters.limit;
if (filters.offset !== undefined) queryParams.offset = filters.offset;
const response = await httpGetRequest<DiscountUsageResponse>(
APIUrlGenerator(API_ROUTES.DISCOUNT_USAGE_REPORT, queryParams)
const response = await httpPostRequest<DiscountUsageResponse>(
APIUrlGenerator(API_ROUTES.DISCOUNT_USAGE_REPORT),
filters
);
return response.data;
};
@ -30,18 +20,9 @@ export const getDiscountUsageReport = async (
export const getCustomerDiscountUsageReport = async (
filters: CustomerDiscountUsageFilters
): Promise<CustomerDiscountUsageResponse> => {
const queryParams: Record<string, string | number> = {};
queryParams.user_id = filters.user_id;
if (filters.date_range?.from) queryParams.from = filters.date_range.from;
if (filters.date_range?.to) queryParams.to = filters.date_range.to;
if (filters.discount_code) queryParams.discount_code = filters.discount_code;
if (filters.discount_id !== undefined) queryParams.discount_id = filters.discount_id;
if (filters.limit !== undefined) queryParams.limit = filters.limit;
if (filters.offset !== undefined) queryParams.offset = filters.offset;
const response = await httpGetRequest<CustomerDiscountUsageResponse>(
APIUrlGenerator(API_ROUTES.CUSTOMER_DISCOUNT_USAGE_REPORT, queryParams)
const response = await httpPostRequest<CustomerDiscountUsageResponse>(
APIUrlGenerator(API_ROUTES.CUSTOMER_DISCOUNT_USAGE_REPORT),
filters
);
return response.data;
};

View File

@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import React, { useState } from 'react';
import { useCustomerDiscountUsageReport } from '../core/_hooks';
import { CustomerDiscountUsageFilters } from '../core/_models';
import { Button } from '@/components/ui/Button';
@ -22,35 +22,24 @@ const CustomerDiscountUsagePage = () => {
offset: 0,
});
const [tempFilters, setTempFilters] = useState<CustomerDiscountUsageFilters>({
user_id: 0,
limit: 50,
offset: 0,
});
const { data, isLoading, error } = useCustomerDiscountUsageReport(filters);
const [refetchKey, setRefetchKey] = useState(0);
const filtersWithKey = useMemo(() => ({
...filters,
_refetchKey: refetchKey,
}), [filters, refetchKey]);
const { data, isLoading, error } = useCustomerDiscountUsageReport(filtersWithKey as CustomerDiscountUsageFilters & { _refetchKey?: number });
const handleTempFilterChange = (key: keyof CustomerDiscountUsageFilters, value: any) => {
setTempFilters(prev => ({
const handleFilterChange = (key: keyof CustomerDiscountUsageFilters, value: any) => {
setFilters(prev => ({
...prev,
[key]: value,
offset: 0,
}));
};
const handleDateRangeChange = (from: string | undefined, to: string | undefined) => {
setTempFilters(prev => ({
setFilters(prev => ({
...prev,
date_range: {
from,
to,
},
offset: 0,
}));
};
@ -58,21 +47,12 @@ const CustomerDiscountUsagePage = () => {
const converted = persianToEnglish(raw).replace(/[^\d]/g, '');
const numeric = converted ? Number(converted) : undefined;
if (key === 'user_id') {
handleTempFilterChange('user_id', numeric || 0);
handleFilterChange('user_id', numeric || 0);
} else {
handleTempFilterChange(key, numeric);
handleFilterChange(key, numeric);
}
};
const handleApplyFilters = () => {
const newFilters = {
...tempFilters,
offset: 0,
};
setFilters(newFilters);
setRefetchKey(prev => prev + 1);
};
const handlePageChange = (page: number) => {
setFilters(prev => ({
...prev,
@ -81,13 +61,11 @@ const CustomerDiscountUsagePage = () => {
};
const handleClearFilters = () => {
const clearedFilters = {
setFilters({
user_id: 0,
limit: 50,
offset: 0,
};
setTempFilters(clearedFilters);
setFilters(clearedFilters);
});
};
const columns: TableColumn[] = [
@ -140,31 +118,15 @@ const CustomerDiscountUsagePage = () => {
<Filter className="h-5 w-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleApplyFilters}
disabled={!tempFilters.user_id || tempFilters.user_id === 0}
className="flex items-center gap-2"
>
<Filter className="h-4 w-4" />
اعمال فیلترها
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleClearFilters}
className="flex items-center gap-2"
>
<X className="h-4 w-4" />
پاک کردن فیلترها
</Button>
</div>
</div>
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-sm text-blue-800 dark:text-blue-200">
<span className="font-semibold">توجه:</span> برای مشاهده گزارش، لطفاً شناسه کاربر را وارد کنید. این فیلد الزامی است.
</p>
<Button
variant="secondary"
size="sm"
onClick={handleClearFilters}
className="flex items-center gap-2"
>
<X className="h-4 w-4" />
پاک کردن فیلترها
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@ -173,7 +135,7 @@ const CustomerDiscountUsagePage = () => {
شناسه کاربر <span className="text-red-500">*</span>
</label>
<Input
value={tempFilters.user_id?.toString() || ''}
value={filters.user_id?.toString() || ''}
onChange={(e) => handleNumericFilterChange('user_id', e.target.value)}
placeholder="مثلاً 456"
numeric
@ -186,8 +148,8 @@ const CustomerDiscountUsagePage = () => {
کد تخفیف
</label>
<Input
value={tempFilters.discount_code || ''}
onChange={(e) => handleTempFilterChange('discount_code', e.target.value || undefined)}
value={filters.discount_code || ''}
onChange={(e) => handleFilterChange('discount_code', e.target.value || undefined)}
placeholder="مثلاً SUMMER2025"
/>
</div>
@ -197,7 +159,7 @@ const CustomerDiscountUsagePage = () => {
شناسه کد تخفیف
</label>
<Input
value={tempFilters.discount_id?.toString() || ''}
value={filters.discount_id?.toString() || ''}
onChange={(e) => handleNumericFilterChange('discount_id', e.target.value)}
placeholder="مثلاً 123"
numeric
@ -209,8 +171,8 @@ const CustomerDiscountUsagePage = () => {
تاریخ شروع
</label>
<JalaliDateTimePicker
value={tempFilters.date_range?.from}
onChange={(value) => handleDateRangeChange(value, tempFilters.date_range?.to)}
value={filters.date_range?.from}
onChange={(value) => handleDateRangeChange(value, filters.date_range?.to)}
placeholder="انتخاب تاریخ شروع"
/>
</div>
@ -220,8 +182,8 @@ const CustomerDiscountUsagePage = () => {
تاریخ پایان
</label>
<JalaliDateTimePicker
value={tempFilters.date_range?.to}
onChange={(value) => handleDateRangeChange(tempFilters.date_range?.from, value)}
value={filters.date_range?.to}
onChange={(value) => handleDateRangeChange(filters.date_range?.from, value)}
placeholder="انتخاب تاریخ پایان"
/>
</div>

View File

@ -1,10 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/utils/query-key";
import { getInventoryValueReport } from "./_requests";
export const useInventoryValueReport = () => {
return useQuery({
queryKey: [QUERY_KEYS.GET_INVENTORY_VALUE_REPORT],
queryFn: () => getInventoryValueReport(),
});
};

View File

@ -1,19 +0,0 @@
export interface FactoryFeeBreakdown {
factory_fee_percentage: number;
raw_weight: number;
weight_with_fee: number;
amount: number;
variants_count: number;
stock_quantity: number;
}
export interface InventoryValueResponse {
raw_inventory_amount: number;
raw_inventory_weight: number;
factory_fee_inventory_amount: number;
factory_fee_inventory_weight: number;
factory_fee_breakdown: FactoryFeeBreakdown[];
current_gold_price: number;
total_variants_count: number;
total_stock_quantity: number;
}

View File

@ -1,10 +0,0 @@
import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import { InventoryValueResponse } from "./_models";
export const getInventoryValueReport = async (): Promise<InventoryValueResponse> => {
const response = await httpGetRequest<InventoryValueResponse>(
APIUrlGenerator(API_ROUTES.INVENTORY_VALUE_REPORT)
);
return response.data;
};

View File

@ -1,203 +0,0 @@
import React, { useState, useMemo } from 'react';
import { useInventoryValueReport } from '../core/_hooks';
import { Table } from '@/components/ui/Table';
import { TableColumn } from '@/types';
import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { Pagination } from '@/components/ui/Pagination';
import { DollarSign, Package, TrendingUp, Hash } from 'lucide-react';
import { formatWithThousands } from '@/utils/numberUtils';
import { formatCurrency } from '@/utils/formatters';
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
const InventoryValueReportPage = () => {
const { data, isLoading, error } = useInventoryValueReport();
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const columns: TableColumn[] = useMemo(() => [
{
key: 'factory_fee_percentage',
label: 'درصد اجرت کارخانه',
align: 'right',
render: (val: number) => `${formatWithThousands(val, 1)}%`,
},
{
key: 'raw_weight',
label: 'وزن خام (گرم)',
align: 'right',
render: (val: number) => formatWithThousands(val, 2) + ' گرم',
},
{
key: 'weight_with_fee',
label: 'وزن با اجرت (گرم)',
align: 'right',
render: (val: number) => formatWithThousands(val, 2) + ' گرم',
},
{
key: 'amount',
label: 'ارزش (تومان)',
align: 'right',
render: (val: number) => formatCurrency(val),
},
{
key: 'variants_count',
label: 'تعداد Variant',
align: 'right',
render: (val: number) => formatWithThousands(val),
},
{
key: 'stock_quantity',
label: 'موجودی',
align: 'right',
render: (val: number) => formatWithThousands(val),
},
], []);
if (isLoading) {
return (
<PageContainer>
<ReportSkeleton />
</PageContainer>
);
}
if (error) {
return (
<PageContainer>
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">خطا در بارگذاری گزارش</p>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageTitle>گزارش ارزش موجودی</PageTitle>
{data && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<DollarSign className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">ارزش موجودی خام</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(data.raw_inventory_amount)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
<DollarSign className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">ارزش با اجرت کارخانه</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(data.factory_fee_inventory_amount)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
<Package className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">وزن خام</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.raw_inventory_weight, 2)} گرم
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
<TrendingUp className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">قیمت لحظهای طلا</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(data.current_gold_price)}/گرم
</p>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-100 dark:bg-indigo-900 rounded-lg">
<Hash className="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">تعداد کل Variant</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.total_variants_count)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-teal-100 dark:bg-teal-900 rounded-lg">
<Package className="h-5 w-5 text-teal-600 dark:text-teal-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">موجودی کل</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.total_stock_quantity)}
</p>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
تفکیک بر اساس درصد اجرت کارخانه
</h3>
{(() => {
const breakdownData = data.factory_fee_breakdown || [];
const totalPages = Math.ceil(breakdownData.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedData = breakdownData.slice(startIndex, endIndex);
return (
<>
<Table columns={columns} data={paginatedData} />
{totalPages > 1 && (
<div className="mt-4">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
itemsPerPage={itemsPerPage}
totalItems={breakdownData.length}
/>
</div>
)}
</>
);
})()}
</div>
</>
)}
</PageContainer>
);
};
export default InventoryValueReportPage;

View File

@ -36,42 +36,31 @@ const PaymentMethodsReportPage = () => {
group_by_user: false,
});
const [tempFilters, setTempFilters] = useState<PaymentMethodsFilters>({
limit: 50,
offset: 0,
group_by_user: false,
});
const { data, isLoading, error } = usePaymentMethodsReport(filters);
const handleTempFilterChange = (key: keyof PaymentMethodsFilters, value: any) => {
setTempFilters(prev => ({
const handleFilterChange = (key: keyof PaymentMethodsFilters, value: any) => {
setFilters(prev => ({
...prev,
[key]: value,
offset: 0,
}));
};
const handleDateRangeChange = (from: string | undefined, to: string | undefined) => {
setTempFilters(prev => ({
setFilters(prev => ({
...prev,
date_range: {
from,
to,
},
offset: 0,
}));
};
const handleNumericFilterChange = (key: 'user_id', raw: string) => {
const converted = persianToEnglish(raw).replace(/[^\d]/g, '');
const numeric = converted ? Number(converted) : undefined;
handleTempFilterChange(key, numeric);
};
const handleApplyFilters = () => {
setFilters({
...tempFilters,
offset: 0,
});
handleFilterChange(key, numeric);
};
const handlePageChange = (page: number) => {
@ -82,13 +71,11 @@ const PaymentMethodsReportPage = () => {
};
const handleClearFilters = () => {
const clearedFilters = {
setFilters({
limit: 50,
offset: 0,
group_by_user: false,
};
setTempFilters(clearedFilters);
setFilters(clearedFilters);
});
};
const columns: TableColumn[] = [
@ -171,24 +158,15 @@ const PaymentMethodsReportPage = () => {
<Filter className="h-5 w-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleApplyFilters}
className="flex items-center gap-2"
>
<Filter className="h-4 w-4" />
اعمال فیلترها
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleClearFilters}
className="flex items-center gap-2"
>
<X className="h-4 w-4" />
پاک کردن فیلترها
</Button>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleClearFilters}
className="flex items-center gap-2"
>
<X className="h-4 w-4" />
پاک کردن فیلترها
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@ -197,7 +175,7 @@ const PaymentMethodsReportPage = () => {
شناسه کاربر
</label>
<Input
value={tempFilters.user_id?.toString() || ''}
value={filters.user_id?.toString() || ''}
onChange={(e) => handleNumericFilterChange('user_id', e.target.value)}
placeholder="مثلاً 456"
numeric
@ -209,8 +187,8 @@ const PaymentMethodsReportPage = () => {
نوع پرداخت
</label>
<select
value={tempFilters.payment_type || ''}
onChange={(e) => handleTempFilterChange('payment_type', e.target.value || undefined)}
value={filters.payment_type || ''}
onChange={(e) => handleFilterChange('payment_type', e.target.value || undefined)}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
>
<option value="">همه</option>
@ -226,8 +204,8 @@ const PaymentMethodsReportPage = () => {
وضعیت
</label>
<select
value={tempFilters.status || ''}
onChange={(e) => handleTempFilterChange('status', e.target.value || undefined)}
value={filters.status || ''}
onChange={(e) => handleFilterChange('status', e.target.value || undefined)}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
>
<option value="">همه</option>
@ -244,8 +222,8 @@ const PaymentMethodsReportPage = () => {
تاریخ شروع
</label>
<JalaliDateTimePicker
value={tempFilters.date_range?.from}
onChange={(value) => handleDateRangeChange(value, tempFilters.date_range?.to)}
value={filters.date_range?.from}
onChange={(value) => handleDateRangeChange(value, filters.date_range?.to)}
placeholder="انتخاب تاریخ شروع"
/>
</div>
@ -255,8 +233,8 @@ const PaymentMethodsReportPage = () => {
تاریخ پایان
</label>
<JalaliDateTimePicker
value={tempFilters.date_range?.to}
onChange={(value) => handleDateRangeChange(tempFilters.date_range?.from, value)}
value={filters.date_range?.to}
onChange={(value) => handleDateRangeChange(filters.date_range?.from, value)}
placeholder="انتخاب تاریخ پایان"
/>
</div>
@ -265,8 +243,8 @@ const PaymentMethodsReportPage = () => {
<label className="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={tempFilters.group_by_user || false}
onChange={(e) => handleTempFilterChange('group_by_user', e.target.checked)}
checked={filters.group_by_user || false}
onChange={(e) => handleFilterChange('group_by_user', e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600"
/>
گروهبندی بر اساس کاربر

View File

@ -1,11 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/utils/query-key";
import { getProfitLossReport } from "./_requests";
import { ProfitLossFilters } from "./_models";
export const useProfitLossReport = (filters?: ProfitLossFilters) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_PROFIT_LOSS_REPORT, filters],
queryFn: () => getProfitLossReport(filters),
});
};

View File

@ -1,45 +0,0 @@
export interface ProfitLossFilters {
from?: string;
to?: string;
product_sku?: string;
category_name?: string;
min_profit_loss_grams?: number;
max_profit_loss_grams?: number;
min_profit_loss_tomans?: number;
max_profit_loss_tomans?: number;
}
export interface ProfitLossPeriod {
from: string | null;
to: string | null;
}
export interface ProductBreakdown {
product_id: number;
product_name: string;
product_sku: string;
profit_loss_grams: number;
profit_loss_tomans: number;
total_quantity: number;
order_items_count: number;
}
export interface CategoryBreakdown {
category_id: number;
category_name: string;
profit_loss_grams: number;
profit_loss_tomans: number;
total_quantity: number;
products_count: number;
order_items_count: number;
}
export interface ProfitLossResponse {
profit_loss: {
profit_loss_grams: number;
profit_loss_tomans: number;
period: ProfitLossPeriod;
product_breakdown: ProductBreakdown[];
category_breakdown: CategoryBreakdown[];
};
}

View File

@ -1,23 +0,0 @@
import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import { ProfitLossFilters, ProfitLossResponse } from "./_models";
export const getProfitLossReport = async (
filters?: ProfitLossFilters
): Promise<ProfitLossResponse> => {
const queryParams: Record<string, string | number> = {};
if (filters?.from) queryParams.from = filters.from;
if (filters?.to) queryParams.to = filters.to;
if (filters?.product_sku) queryParams.product_sku = filters.product_sku;
if (filters?.category_name) queryParams.category_name = filters.category_name;
if (filters?.min_profit_loss_grams !== undefined) queryParams.min_profit_loss_grams = filters.min_profit_loss_grams;
if (filters?.max_profit_loss_grams !== undefined) queryParams.max_profit_loss_grams = filters.max_profit_loss_grams;
if (filters?.min_profit_loss_tomans !== undefined) queryParams.min_profit_loss_tomans = filters.min_profit_loss_tomans;
if (filters?.max_profit_loss_tomans !== undefined) queryParams.max_profit_loss_tomans = filters.max_profit_loss_tomans;
const response = await httpGetRequest<ProfitLossResponse>(
APIUrlGenerator(API_ROUTES.PROFIT_LOSS_REPORT, queryParams)
);
return response.data;
};

View File

@ -1,432 +0,0 @@
import React, { useState, useMemo } from 'react';
import { useProfitLossReport } from '../core/_hooks';
import { ProfitLossFilters } from '../core/_models';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Table } from '@/components/ui/Table';
import { TableColumn } from '@/types';
import { JalaliDateTimePicker } from '@/components/ui/JalaliDateTimePicker';
import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { Pagination } from '@/components/ui/Pagination';
import { Filter, TrendingUp, TrendingDown, DollarSign, Package, X } from 'lucide-react';
import { formatWithThousands, parseFormattedNumber, persianToEnglish } from '@/utils/numberUtils';
import { formatCurrency } from '@/utils/formatters';
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
const ProfitLossReportPage = () => {
const [filters, setFilters] = useState<ProfitLossFilters>({});
const [tempFilters, setTempFilters] = useState<ProfitLossFilters>({});
const [productPage, setProductPage] = useState(1);
const [categoryPage, setCategoryPage] = useState(1);
const itemsPerPage = 10;
const { data, isLoading, error } = useProfitLossReport(filters);
const handleTempFilterChange = (key: keyof ProfitLossFilters, value: any) => {
setTempFilters(prev => ({
...prev,
[key]: value,
}));
};
const handleDateChange = (key: 'from' | 'to', value: string | undefined) => {
handleTempFilterChange(key, value);
};
const handleNumericFilterChange = (
key: 'min_profit_loss_grams' | 'max_profit_loss_grams' | 'min_profit_loss_tomans' | 'max_profit_loss_tomans',
raw: string
) => {
const converted = persianToEnglish(raw);
const numeric = parseFormattedNumber(converted);
handleTempFilterChange(key, numeric || undefined);
};
const handleApplyFilters = () => {
setFilters(tempFilters);
setProductPage(1);
setCategoryPage(1);
};
const handleClearFilters = () => {
const clearedFilters = {};
setTempFilters(clearedFilters);
setFilters(clearedFilters);
setProductPage(1);
setCategoryPage(1);
};
const productColumns: TableColumn[] = useMemo(() => [
{
key: 'product_name',
label: 'نام محصول',
align: 'right',
render: (_val, row: any) => (
<div>
<div className="font-medium">{row.product_name}</div>
{row.product_sku && (
<div className="text-xs text-gray-500 dark:text-gray-400">SKU: {row.product_sku}</div>
)}
</div>
),
},
{
key: 'profit_loss_grams',
label: 'سود/زیان (گرم)',
align: 'right',
render: (val: number) => (
<span className={val >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
{val >= 0 ? '+' : ''}{formatWithThousands(val, 2)} گرم
</span>
),
},
{
key: 'profit_loss_tomans',
label: 'سود/زیان (تومان)',
align: 'right',
render: (val: number) => (
<span className={val >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
{val >= 0 ? '+' : ''}{formatCurrency(val)}
</span>
),
},
{
key: 'total_quantity',
label: 'تعداد فروش',
align: 'right',
render: (val: number) => formatWithThousands(val),
},
{
key: 'order_items_count',
label: 'تعداد آیتم‌ها',
align: 'right',
render: (val: number) => formatWithThousands(val),
},
], []);
const categoryColumns: TableColumn[] = useMemo(() => [
{
key: 'category_name',
label: 'نام دسته‌بندی',
align: 'right',
},
{
key: 'profit_loss_grams',
label: 'سود/زیان (گرم)',
align: 'right',
render: (val: number) => (
<span className={val >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
{val >= 0 ? '+' : ''}{formatWithThousands(val, 2)} گرم
</span>
),
},
{
key: 'profit_loss_tomans',
label: 'سود/زیان (تومان)',
align: 'right',
render: (val: number) => (
<span className={val >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
{val >= 0 ? '+' : ''}{formatCurrency(val)}
</span>
),
},
{
key: 'total_quantity',
label: 'تعداد فروش',
align: 'right',
render: (val: number) => formatWithThousands(val),
},
{
key: 'products_count',
label: 'تعداد محصولات',
align: 'right',
render: (val: number) => formatWithThousands(val),
},
{
key: 'order_items_count',
label: 'تعداد آیتم‌ها',
align: 'right',
render: (val: number) => formatWithThousands(val),
},
], []);
const profitLossData = data?.profit_loss;
const isProfit = profitLossData ? profitLossData.profit_loss_grams >= 0 : false;
if (isLoading) {
return (
<PageContainer>
<ReportSkeleton />
</PageContainer>
);
}
if (error) {
return (
<PageContainer>
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">خطا در بارگذاری گزارش</p>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageTitle>گزارش سود و زیان</PageTitle>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleApplyFilters}
className="flex items-center gap-2"
>
<Filter className="h-4 w-4" />
اعمال فیلترها
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleClearFilters}
className="flex items-center gap-2"
>
<X className="h-4 w-4" />
پاک کردن فیلترها
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
تاریخ شروع
</label>
<JalaliDateTimePicker
value={tempFilters.from}
onChange={(value) => handleDateChange('from', value)}
placeholder="انتخاب تاریخ شروع"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
تاریخ پایان
</label>
<JalaliDateTimePicker
value={tempFilters.to}
onChange={(value) => handleDateChange('to', value)}
placeholder="انتخاب تاریخ پایان"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SKU محصول
</label>
<Input
value={tempFilters.product_sku || ''}
onChange={(e) => handleTempFilterChange('product_sku', e.target.value || undefined)}
placeholder="مثلاً RING-001"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
نام دستهبندی
</label>
<Input
value={tempFilters.category_name || ''}
onChange={(e) => handleTempFilterChange('category_name', e.target.value || undefined)}
placeholder="مثلاً ring"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حداقل سود/زیان (گرم)
</label>
<Input
value={tempFilters.min_profit_loss_grams?.toString() || ''}
onChange={(e) => handleNumericFilterChange('min_profit_loss_grams', e.target.value)}
placeholder="مثلاً ۱۰.۵"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حداکثر سود/زیان (گرم)
</label>
<Input
value={tempFilters.max_profit_loss_grams?.toString() || ''}
onChange={(e) => handleNumericFilterChange('max_profit_loss_grams', e.target.value)}
placeholder="مثلاً ۱۰۰"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حداقل سود/زیان (تومان)
</label>
<Input
value={tempFilters.min_profit_loss_tomans?.toString() || ''}
onChange={(e) => handleNumericFilterChange('min_profit_loss_tomans', e.target.value)}
placeholder="مثلاً ۱۰۰۰۰۰۰"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حداکثر سود/زیان (تومان)
</label>
<Input
value={tempFilters.max_profit_loss_tomans?.toString() || ''}
onChange={(e) => handleNumericFilterChange('max_profit_loss_tomans', e.target.value)}
placeholder="مثلاً ۵۰۰۰۰۰۰۰"
numeric
/>
</div>
</div>
</div>
{profitLossData && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div className={`bg-white dark:bg-gray-800 shadow-sm border-2 rounded-lg p-6 ${
isProfit
? 'border-green-500 dark:border-green-400'
: 'border-red-500 dark:border-red-400'
}`}>
<div className="flex items-center gap-3">
<div className={`p-3 rounded-lg ${
isProfit
? 'bg-green-100 dark:bg-green-900'
: 'bg-red-100 dark:bg-red-900'
}`}>
{isProfit ? (
<TrendingUp className="h-6 w-6 text-green-600 dark:text-green-400" />
) : (
<TrendingDown className="h-6 w-6 text-red-600 dark:text-red-400" />
)}
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">سود/زیان کل (گرم)</p>
<p className={`text-2xl font-bold ${
isProfit
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}>
{profitLossData.profit_loss_grams >= 0 ? '+' : ''}
{formatWithThousands(profitLossData.profit_loss_grams, 2)} گرم
</p>
</div>
</div>
</div>
<div className={`bg-white dark:bg-gray-800 shadow-sm border-2 rounded-lg p-6 ${
isProfit
? 'border-green-500 dark:border-green-400'
: 'border-red-500 dark:border-red-400'
}`}>
<div className="flex items-center gap-3">
<div className={`p-3 rounded-lg ${
isProfit
? 'bg-green-100 dark:bg-green-900'
: 'bg-red-100 dark:bg-red-900'
}`}>
{isProfit ? (
<DollarSign className="h-6 w-6 text-green-600 dark:text-green-400" />
) : (
<DollarSign className="h-6 w-6 text-red-600 dark:text-red-400" />
)}
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">سود/زیان کل (تومان)</p>
<p className={`text-2xl font-bold ${
isProfit
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}>
{profitLossData.profit_loss_tomans >= 0 ? '+' : ''}
{formatCurrency(profitLossData.profit_loss_tomans)}
</p>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
تفکیک محصولات
</h3>
{(() => {
const productData = profitLossData.product_breakdown || [];
const productTotalPages = Math.ceil(productData.length / itemsPerPage);
const productStartIndex = (productPage - 1) * itemsPerPage;
const productEndIndex = productStartIndex + itemsPerPage;
const paginatedProductData = productData.slice(productStartIndex, productEndIndex);
return (
<>
<Table columns={productColumns} data={paginatedProductData} />
{productTotalPages > 1 && (
<div className="mt-4">
<Pagination
currentPage={productPage}
totalPages={productTotalPages}
onPageChange={setProductPage}
itemsPerPage={itemsPerPage}
totalItems={productData.length}
/>
</div>
)}
</>
);
})()}
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
تفکیک دستهبندیها
</h3>
{(() => {
const categoryData = profitLossData.category_breakdown || [];
const categoryTotalPages = Math.ceil(categoryData.length / itemsPerPage);
const categoryStartIndex = (categoryPage - 1) * itemsPerPage;
const categoryEndIndex = categoryStartIndex + itemsPerPage;
const paginatedCategoryData = categoryData.slice(categoryStartIndex, categoryEndIndex);
return (
<>
<Table columns={categoryColumns} data={paginatedCategoryData} />
{categoryTotalPages > 1 && (
<div className="mt-4">
<Pagination
currentPage={categoryPage}
totalPages={categoryTotalPages}
onPageChange={setCategoryPage}
itemsPerPage={itemsPerPage}
totalItems={categoryData.length}
/>
</div>
)}
</>
);
})()}
</div>
</div>
</>
)}
</PageContainer>
);
};
export default ProfitLossReportPage;

View File

@ -1,11 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/utils/query-key";
import { getSalesSummaryReport } from "./_requests";
import { SalesSummaryFilters } from "./_models";
export const useSalesSummaryReport = (filters: SalesSummaryFilters) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_SALES_SUMMARY_REPORT, filters],
queryFn: () => getSalesSummaryReport(filters),
});
};

View File

@ -1,61 +0,0 @@
export interface SalesSummaryFilters {
from: string;
to: string;
status?: string[];
product_sku?: string;
product_name?: string;
min_quantity?: number;
max_quantity?: number;
min_weight?: number;
max_weight?: number;
min_sales?: number;
max_sales?: number;
limit?: number;
offset?: number;
}
export interface SalesSummaryPeriod {
from: string;
to: string;
}
export interface SalesSummaryFiltersApplied {
status: string[];
}
export interface ProductBreakdown {
product_id: number;
product_sku: string;
product_name: string;
total_weight: number;
total_final_weight: number;
total_quantity: number;
total_sales_amount: number;
average_price: number;
average_weight: number;
variant_count: number;
image_url?: string;
thumbnail_url?: string;
}
export interface ProductsPagination {
total: number;
limit: number;
offset: number;
has_more: boolean;
}
export interface SalesSummaryResponse {
total_sales_amount: number;
total_gold_weight: number;
total_final_weight: number;
total_orders: number;
average_order_value: number;
average_price: number;
average_weight: number;
total_discount: number;
products_breakdown: ProductBreakdown[];
products_pagination: ProductsPagination;
period: SalesSummaryPeriod;
filters: SalesSummaryFiltersApplied;
}

View File

@ -1,29 +0,0 @@
import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import { SalesSummaryFilters, SalesSummaryResponse } from "./_models";
export const getSalesSummaryReport = async (
filters: SalesSummaryFilters
): Promise<SalesSummaryResponse> => {
const queryParams: Record<string, string | number | string[]> = {};
queryParams.from = filters.from;
queryParams.to = filters.to;
if (filters.status) queryParams.status = filters.status;
if (filters.product_sku) queryParams.product_sku = filters.product_sku;
if (filters.product_name) queryParams.product_name = filters.product_name;
if (filters.min_quantity !== undefined) queryParams.min_quantity = filters.min_quantity;
if (filters.max_quantity !== undefined) queryParams.max_quantity = filters.max_quantity;
if (filters.min_weight !== undefined) queryParams.min_weight = filters.min_weight;
if (filters.max_weight !== undefined) queryParams.max_weight = filters.max_weight;
if (filters.min_sales !== undefined) queryParams.min_sales = filters.min_sales;
if (filters.max_sales !== undefined) queryParams.max_sales = filters.max_sales;
if (filters.limit !== undefined) queryParams.limit = filters.limit;
if (filters.offset !== undefined) queryParams.offset = filters.offset;
const response = await httpGetRequest<SalesSummaryResponse>(
APIUrlGenerator(API_ROUTES.SALES_SUMMARY_REPORT, queryParams)
);
return response.data;
};

View File

@ -1,440 +0,0 @@
import React, { useState, useMemo } from 'react';
import { useSalesSummaryReport } from '../core/_hooks';
import { SalesSummaryFilters } from '../core/_models';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Table } from '@/components/ui/Table';
import { TableColumn } from '@/types';
import { JalaliDateTimePicker } from '@/components/ui/JalaliDateTimePicker';
import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { Pagination } from '@/components/ui/Pagination';
import { Filter, TrendingUp, DollarSign, Package, ShoppingCart, X, Image as ImageIcon } from 'lucide-react';
import { formatWithThousands, parseFormattedNumber, persianToEnglish } from '@/utils/numberUtils';
import { formatCurrency } from '@/utils/formatters';
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
import DateObject from 'react-date-object';
const toIsoString = (date: DateObject): string => {
try {
const g = date.convert(undefined);
const yyyy = g.year.toString().padStart(4, '0');
const mm = g.month.toString().padStart(2, '0');
const dd = g.day.toString().padStart(2, '0');
const hh = (g.hour || 0).toString().padStart(2, '0');
const mi = (g.minute || 0).toString().padStart(2, '0');
const ss = (g.second || 0).toString().padStart(2, '0');
return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}Z`;
} catch {
const now = new Date();
return now.toISOString();
}
};
const getDefaultDateRange = () => {
const now = new DateObject();
const thirtyDaysAgo = new DateObject().subtract(30, 'days');
return {
from: toIsoString(thirtyDaysAgo),
to: toIsoString(now),
};
};
const SalesSummaryReportPage = () => {
const defaultDates = getDefaultDateRange();
const [filters, setFilters] = useState<SalesSummaryFilters>({
from: defaultDates.from,
to: defaultDates.to,
limit: 50,
offset: 0,
});
const [tempFilters, setTempFilters] = useState<SalesSummaryFilters>({
from: defaultDates.from,
to: defaultDates.to,
limit: 50,
offset: 0,
});
const { data, isLoading, error } = useSalesSummaryReport(filters);
const handleTempFilterChange = (key: keyof SalesSummaryFilters, value: any) => {
setTempFilters(prev => ({
...prev,
[key]: value,
}));
};
const handleDateChange = (key: 'from' | 'to', value: string | undefined) => {
if (value) {
handleTempFilterChange(key, value);
}
};
const handleNumericFilterChange = (
key: 'min_quantity' | 'max_quantity' | 'min_weight' | 'max_weight' | 'min_sales' | 'max_sales',
raw: string
) => {
const converted = persianToEnglish(raw);
const numeric = parseFormattedNumber(converted);
handleTempFilterChange(key, numeric || undefined);
};
const handleApplyFilters = () => {
setFilters({
...tempFilters,
offset: 0,
});
};
const handlePageChange = (page: number) => {
setFilters(prev => ({
...prev,
offset: (page - 1) * (prev.limit || 50),
}));
};
const handleClearFilters = () => {
const defaultDates = getDefaultDateRange();
const clearedFilters = {
from: defaultDates.from,
to: defaultDates.to,
limit: 50,
offset: 0,
};
setTempFilters(clearedFilters);
setFilters(clearedFilters);
};
const columns: TableColumn[] = useMemo(() => [
{
key: 'product_name',
label: 'نام محصول',
align: 'right',
render: (_val, row: any) => (
<div className="flex items-center gap-3">
{row.image_url && (
<img
src={row.image_url}
alt={row.product_name}
className="w-10 h-10 object-cover rounded"
/>
)}
<div>
<div className="font-medium">{row.product_name}</div>
{row.product_sku && (
<div className="text-xs text-gray-500 dark:text-gray-400">SKU: {row.product_sku}</div>
)}
</div>
</div>
),
},
{
key: 'total_quantity',
label: 'تعداد فروش',
align: 'right',
render: (val: number) => formatWithThousands(val),
},
{
key: 'total_weight',
label: 'وزن خالص (گرم)',
align: 'right',
render: (val: number) => formatWithThousands(val, 2),
},
{
key: 'total_final_weight',
label: 'وزن با اجرت (گرم)',
align: 'right',
render: (val: number) => formatWithThousands(val, 2),
},
{
key: 'total_sales_amount',
label: 'مجموع فروش',
align: 'right',
render: (val: number) => formatCurrency(val),
},
{
key: 'average_price',
label: 'میانگین قیمت',
align: 'right',
render: (val: number) => formatCurrency(val),
},
{
key: 'average_weight',
label: 'میانگین وزن',
align: 'right',
render: (val: number) => formatWithThousands(val, 2) + ' گرم',
},
{
key: 'variant_count',
label: 'تعداد Variant',
align: 'right',
render: (val: number) => formatWithThousands(val),
},
], []);
const tableData = (data?.products_breakdown || []).map(product => ({
...product,
total_quantity: product.total_quantity,
total_weight: product.total_weight,
total_final_weight: product.total_final_weight,
total_sales_amount: product.total_sales_amount,
average_price: product.average_price,
average_weight: product.average_weight,
variant_count: product.variant_count,
}));
const currentPage = Math.floor((filters.offset || 0) / (filters.limit || 50)) + 1;
const totalPages = data?.products_pagination ? Math.ceil(data.products_pagination.total / (filters.limit || 50)) : 1;
if (isLoading) {
return (
<PageContainer>
<ReportSkeleton />
</PageContainer>
);
}
if (error) {
return (
<PageContainer>
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">خطا در بارگذاری گزارش</p>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageTitle>گزارش خلاصه فروش</PageTitle>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleApplyFilters}
className="flex items-center gap-2"
>
<Filter className="h-4 w-4" />
اعمال فیلترها
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleClearFilters}
className="flex items-center gap-2"
>
<X className="h-4 w-4" />
پاک کردن فیلترها
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
تاریخ شروع (الزامی)
</label>
<JalaliDateTimePicker
value={tempFilters.from}
onChange={(value) => handleDateChange('from', value)}
placeholder="انتخاب تاریخ شروع"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
تاریخ پایان (الزامی)
</label>
<JalaliDateTimePicker
value={tempFilters.to}
onChange={(value) => handleDateChange('to', value)}
placeholder="انتخاب تاریخ پایان"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SKU محصول
</label>
<Input
value={tempFilters.product_sku || ''}
onChange={(e) => handleTempFilterChange('product_sku', e.target.value || undefined)}
placeholder="مثلاً RING-001"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
نام محصول
</label>
<Input
value={tempFilters.product_name || ''}
onChange={(e) => handleTempFilterChange('product_name', e.target.value || undefined)}
placeholder="مثلاً انگشتر"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حداقل تعداد
</label>
<Input
value={tempFilters.min_quantity?.toString() || ''}
onChange={(e) => handleNumericFilterChange('min_quantity', e.target.value)}
placeholder="مثلاً ۱۰"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حداکثر تعداد
</label>
<Input
value={tempFilters.max_quantity?.toString() || ''}
onChange={(e) => handleNumericFilterChange('max_quantity', e.target.value)}
placeholder="مثلاً ۱۰۰"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حداقل وزن (گرم)
</label>
<Input
value={tempFilters.min_weight?.toString() || ''}
onChange={(e) => handleNumericFilterChange('min_weight', e.target.value)}
placeholder="مثلاً ۵.۵"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حداکثر وزن (گرم)
</label>
<Input
value={tempFilters.max_weight?.toString() || ''}
onChange={(e) => handleNumericFilterChange('max_weight', e.target.value)}
placeholder="مثلاً ۵۰"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حداقل فروش (تومان)
</label>
<Input
value={tempFilters.min_sales?.toString() || ''}
onChange={(e) => handleNumericFilterChange('min_sales', e.target.value)}
placeholder="مثلاً ۱۰۰۰۰۰۰"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حداکثر فروش (تومان)
</label>
<Input
value={tempFilters.max_sales?.toString() || ''}
onChange={(e) => handleNumericFilterChange('max_sales', e.target.value)}
placeholder="مثلاً ۵۰۰۰۰۰۰۰"
numeric
/>
</div>
</div>
</div>
{data && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<DollarSign className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">مجموع فروش</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(data.total_sales_amount)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
<Package className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">وزن خالص طلا</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.total_gold_weight, 2)} گرم
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
<ShoppingCart className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">تعداد سفارشات</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.total_orders)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
<TrendingUp className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">میانگین سفارش</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(data.average_order_value)}
</p>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
تفکیک محصولات
</h3>
<Table columns={columns} data={tableData} />
{data.products_pagination && totalPages > 1 && (
<div className="mt-4">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
itemsPerPage={filters.limit || 50}
totalItems={data.products_pagination.total}
/>
</div>
)}
</div>
</>
)}
</PageContainer>
);
};
export default SalesSummaryReportPage;

View File

@ -1,11 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/utils/query-key";
import { getVariantComparisonReport } from "./_requests";
import { VariantComparisonFilters } from "./_models";
export const useVariantComparisonReport = (filters?: VariantComparisonFilters) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_VARIANT_COMPARISON_REPORT, filters],
queryFn: () => getVariantComparisonReport(filters),
});
};

View File

@ -1,53 +0,0 @@
export interface VariantComparisonFilters {
product_id?: number;
product_sku?: string;
variant_color?: string;
variant_size?: string;
enabled?: boolean;
min_stock?: number;
max_stock?: number;
has_stock?: boolean;
min_fee_difference?: number;
max_fee_difference?: number;
limit?: number;
offset?: number;
}
export interface VariantComparisonItem {
variant_id: number;
product_sku: string | null;
product_name: string;
variant_size: string | null;
variant_color: string | null;
weight: number;
fee_percentage: number;
factory_fee_percentage: number;
stock_number: number;
fee_difference: number;
image_url: string | null;
thumbnail_url: string | null;
}
export interface VariantComparisonSummary {
total_variants: number;
total_stock_quantity: number;
average_fee_percentage: number;
average_factory_fee_percentage: number;
average_fee_difference: number;
variants_with_higher_fee: number;
variants_with_lower_fee: number;
variants_with_equal_fee: number;
max_fee_difference: number;
min_fee_difference: number;
total_weight: number;
average_weight: number;
}
export interface VariantComparisonResponse {
variants: VariantComparisonItem[];
summary: VariantComparisonSummary;
total: number;
has_more: boolean;
limit: number;
offset: number;
}

View File

@ -1,27 +0,0 @@
import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import { VariantComparisonFilters, VariantComparisonResponse } from "./_models";
export const getVariantComparisonReport = async (
filters?: VariantComparisonFilters
): Promise<VariantComparisonResponse> => {
const queryParams: Record<string, string | number | boolean> = {};
if (filters?.product_id !== undefined) queryParams.product_id = filters.product_id;
if (filters?.product_sku) queryParams.product_sku = filters.product_sku;
if (filters?.variant_color) queryParams.variant_color = filters.variant_color;
if (filters?.variant_size) queryParams.variant_size = filters.variant_size;
if (filters?.enabled !== undefined) queryParams.enabled = filters.enabled;
if (filters?.min_stock !== undefined) queryParams.min_stock = filters.min_stock;
if (filters?.max_stock !== undefined) queryParams.max_stock = filters.max_stock;
if (filters?.has_stock !== undefined) queryParams.has_stock = filters.has_stock;
if (filters?.min_fee_difference !== undefined) queryParams.min_fee_difference = filters.min_fee_difference;
if (filters?.max_fee_difference !== undefined) queryParams.max_fee_difference = filters.max_fee_difference;
if (filters?.limit !== undefined) queryParams.limit = filters.limit;
if (filters?.offset !== undefined) queryParams.offset = filters.offset;
const response = await httpGetRequest<VariantComparisonResponse>(
APIUrlGenerator(API_ROUTES.VARIANT_COMPARISON_REPORT, queryParams)
);
return response.data;
};

View File

@ -1,395 +0,0 @@
import React, { useState, useMemo } from 'react';
import { useVariantComparisonReport } from '../core/_hooks';
import { VariantComparisonFilters } from '../core/_models';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Table } from '@/components/ui/Table';
import { TableColumn } from '@/types';
import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { Pagination } from '@/components/ui/Pagination';
import { Filter, TrendingUp, TrendingDown, Package, DollarSign, X, Image as ImageIcon } from 'lucide-react';
import { formatWithThousands, parseFormattedNumber, persianToEnglish } from '@/utils/numberUtils';
import { formatCurrency } from '@/utils/formatters';
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
const VariantComparisonReportPage = () => {
const [filters, setFilters] = useState<VariantComparisonFilters>({
limit: 50,
offset: 0,
});
const [tempFilters, setTempFilters] = useState<VariantComparisonFilters>({
limit: 50,
offset: 0,
});
const { data, isLoading, error } = useVariantComparisonReport(filters);
const handleTempFilterChange = (key: keyof VariantComparisonFilters, value: any) => {
setTempFilters(prev => ({
...prev,
[key]: value,
}));
};
const handleApplyFilters = () => {
setFilters({
...tempFilters,
offset: 0,
});
};
const handleClearFilters = () => {
const clearedFilters = {
limit: 50,
offset: 0,
};
setTempFilters(clearedFilters);
setFilters(clearedFilters);
};
const handleNumericFilterChange = (
key: 'product_id' | 'min_stock' | 'max_stock' | 'min_fee_difference' | 'max_fee_difference',
raw: string
) => {
const converted = persianToEnglish(raw);
const numeric = parseFormattedNumber(converted);
handleTempFilterChange(key, numeric || undefined);
};
const handlePageChange = (page: number) => {
setFilters(prev => ({
...prev,
offset: (page - 1) * (prev.limit || 50),
}));
};
const columns: TableColumn[] = useMemo(() => [
{
key: 'product_name',
label: 'محصول',
align: 'right',
render: (_val, row: any) => (
<div className="flex items-center gap-3">
{row.image_url && (
<img
src={row.image_url}
alt={row.product_name}
className="w-10 h-10 object-cover rounded"
/>
)}
<div>
<div className="font-medium">{row.product_name}</div>
{row.product_sku && (
<div className="text-xs text-gray-500 dark:text-gray-400">SKU: {row.product_sku}</div>
)}
{(row.variant_size || row.variant_color) && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{row.variant_size && `سایز: ${row.variant_size}`}
{row.variant_size && row.variant_color && ' - '}
{row.variant_color && `رنگ: ${row.variant_color}`}
</div>
)}
</div>
</div>
),
},
{
key: 'weight',
label: 'وزن (گرم)',
align: 'right',
render: (val: number) => formatWithThousands(val, 2),
},
{
key: 'fee_percentage',
label: 'درصد اجرت مشتری',
align: 'right',
render: (val: number) => `${formatWithThousands(val, 1)}%`,
},
{
key: 'factory_fee_percentage',
label: 'درصد اجرت کارخانه',
align: 'right',
render: (val: number) => `${formatWithThousands(val, 1)}%`,
},
{
key: 'fee_difference',
label: 'سود اجرت',
align: 'right',
render: (val: number) => (
<span className={val >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
{val >= 0 ? '+' : ''}{formatWithThousands(val, 1)}%
</span>
),
},
{
key: 'stock_number',
label: 'موجودی',
align: 'right',
render: (val: number) => formatWithThousands(val),
},
], []);
const tableData = data?.variants || [];
const limit = filters.limit || 50;
const currentPage = Math.floor((filters.offset || 0) / limit) + 1;
const totalPages = data?.total ? Math.ceil(data.total / limit) : 0;
if (isLoading) {
return (
<PageContainer>
<ReportSkeleton />
</PageContainer>
);
}
if (error) {
return (
<PageContainer>
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">خطا در بارگذاری گزارش</p>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageTitle>گزارش مقایسه Variantها</PageTitle>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleApplyFilters}
className="flex items-center gap-2"
>
<Filter className="h-4 w-4" />
اعمال فیلترها
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleClearFilters}
className="flex items-center gap-2"
>
<X className="h-4 w-4" />
پاک کردن فیلترها
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
شناسه محصول
</label>
<Input
value={tempFilters.product_id?.toString() || ''}
onChange={(e) => handleNumericFilterChange('product_id', e.target.value)}
placeholder="مثلاً ۱۲۳"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
SKU محصول
</label>
<Input
value={tempFilters.product_sku || ''}
onChange={(e) => handleTempFilterChange('product_sku', e.target.value || undefined)}
placeholder="مثلاً RING-001"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
رنگ Variant
</label>
<Input
value={tempFilters.variant_color || ''}
onChange={(e) => handleTempFilterChange('variant_color', e.target.value || undefined)}
placeholder="مثلاً زرد"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
سایز Variant
</label>
<Input
value={tempFilters.variant_size || ''}
onChange={(e) => handleTempFilterChange('variant_size', e.target.value || undefined)}
placeholder="مثلاً ۱۸"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
وضعیت فعال
</label>
<select
value={tempFilters.enabled === undefined ? '' : tempFilters.enabled.toString()}
onChange={(e) => handleTempFilterChange('enabled', e.target.value === '' ? undefined : e.target.value === 'true')}
className="input"
>
<option value="">همه</option>
<option value="true">فعال</option>
<option value="false">غیرفعال</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حداقل موجودی
</label>
<Input
value={tempFilters.min_stock?.toString() || ''}
onChange={(e) => handleNumericFilterChange('min_stock', e.target.value)}
placeholder="مثلاً ۵"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حداکثر موجودی
</label>
<Input
value={tempFilters.max_stock?.toString() || ''}
onChange={(e) => handleNumericFilterChange('max_stock', e.target.value)}
placeholder="مثلاً ۱۰۰"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
فقط با موجودی
</label>
<select
value={tempFilters.has_stock === undefined ? '' : tempFilters.has_stock.toString()}
onChange={(e) => handleTempFilterChange('has_stock', e.target.value === '' ? undefined : e.target.value === 'true')}
className="input"
>
<option value="">همه</option>
<option value="true">بله</option>
<option value="false">خیر</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حداقل سود اجرت (%)
</label>
<Input
value={tempFilters.min_fee_difference?.toString() || ''}
onChange={(e) => handleNumericFilterChange('min_fee_difference', e.target.value)}
placeholder="مثلاً ۲.۵"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حداکثر سود اجرت (%)
</label>
<Input
value={tempFilters.max_fee_difference?.toString() || ''}
onChange={(e) => handleNumericFilterChange('max_fee_difference', e.target.value)}
placeholder="مثلاً ۱۰"
numeric
/>
</div>
</div>
</div>
{data && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<Package className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">کل Variantها</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.total_variants)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
<TrendingUp className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Variantهای سودآور</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.variants_with_higher_fee)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-100 dark:bg-red-900 rounded-lg">
<TrendingDown className="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Variantهای زیانده</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.variants_with_lower_fee)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
<DollarSign className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">میانگین سود اجرت</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.average_fee_difference, 1)}%
</p>
</div>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
لیست Variantها
</h3>
<Table columns={columns} data={tableData} />
{data && data.total !== undefined && data.total > 0 && totalPages > 1 && (
<div className="mt-4">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
itemsPerPage={limit}
totalItems={data.total}
/>
</div>
)}
</div>
</>
)}
</PageContainer>
);
};
export default VariantComparisonReportPage;

View File

@ -1,84 +0,0 @@
import React from 'react';
import { Settings as SettingsIcon, UserCheck, UserX } from 'lucide-react';
import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { Button } from '@/components/ui/Button';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { useAutoVerifySetting, useUpdateAutoVerifySetting } from './core/_hooks';
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
const SystemSettingsPage = () => {
const { data, isLoading, error } = useAutoVerifySetting();
const { mutate: updateSetting, isPending } = useUpdateAutoVerifySetting();
const handleToggle = (enabled: boolean) => {
updateSetting({
data: { enabled },
});
};
if (isLoading) {
return (
<PageContainer>
<ReportSkeleton />
</PageContainer>
);
}
if (error) {
return (
<PageContainer>
<div className="text-center py-12">
<p className="text-red-600 dark:text-red-400">
خطا در بارگذاری تنظیمات
</p>
</div>
</PageContainer>
);
}
const isEnabled = data?.setting?.data?.enabled || false;
return (
<PageContainer>
<PageTitle>تنظیمات سیستم</PageTitle>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
{isEnabled ? (
<UserCheck className="h-6 w-6 text-green-600 dark:text-green-400" />
) : (
<UserX className="h-6 w-6 text-gray-400" />
)}
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
تأیید خودکار کاربران جدید
</h3>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mr-9">
در صورت فعال بودن این گزینه، کاربرانی که برای اولین بار ثبتنام میکنند به صورت خودکار تأیید میشوند.
در غیر این صورت، کاربران باید توسط ادمین به صورت دستی تأیید شوند.
</p>
{data?.setting?.updated_at && (
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2 mr-9">
آخرین بهروزرسانی: {new Date(data.setting.updated_at).toLocaleDateString('fa-IR')}
</p>
)}
</div>
<div className="flex items-center gap-4">
{isPending ? (
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900 dark:border-gray-100"></div>
) : (
<ToggleSwitch
checked={isEnabled}
onChange={handleToggle}
/>
)}
</div>
</div>
</div>
</PageContainer>
);
};
export default SystemSettingsPage;

View File

@ -1,40 +0,0 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/utils/query-key";
import {
getAutoVerifySetting,
updateAutoVerifySetting,
} from "./_requests";
import {
UpdateAutoVerifySettingRequest,
AutoVerifySettingResponse,
} from "./_models";
import toast from "react-hot-toast";
export const useAutoVerifySetting = () => {
return useQuery({
queryKey: [QUERY_KEYS.GET_AUTO_VERIFY_SETTING],
queryFn: getAutoVerifySetting,
});
};
export const useUpdateAutoVerifySetting = () => {
const queryClient = useQueryClient();
return useMutation({
mutationKey: [QUERY_KEYS.UPDATE_AUTO_VERIFY_SETTING],
mutationFn: (data: UpdateAutoVerifySettingRequest) =>
updateAutoVerifySetting(data),
onSuccess: (data: AutoVerifySettingResponse) => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_AUTO_VERIFY_SETTING],
});
toast.success(
`تأیید خودکار کاربران جدید ${data.setting.data.enabled ? "فعال" : "غیرفعال"} شد`
);
},
onError: (error: any) => {
console.error("Error updating auto verify setting:", error);
toast.error(error?.message || "خطا در به‌روزرسانی تنظیمات");
},
});
};

View File

@ -1,19 +0,0 @@
export interface AutoVerifySettingData {
enabled: boolean;
}
export interface AutoVerifySetting {
id: number;
name: string;
data: AutoVerifySettingData;
created_at: string;
updated_at: string;
}
export interface AutoVerifySettingResponse {
setting: AutoVerifySetting;
}
export interface UpdateAutoVerifySettingRequest {
data: AutoVerifySettingData;
}

View File

@ -1,23 +0,0 @@
import { httpGetRequest, httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import {
AutoVerifySettingResponse,
UpdateAutoVerifySettingRequest,
} from "./_models";
export const getAutoVerifySetting = async (): Promise<AutoVerifySettingResponse> => {
const response = await httpGetRequest<AutoVerifySettingResponse>(
APIUrlGenerator(API_ROUTES.GET_AUTO_VERIFY_SETTING)
);
return response.data;
};
export const updateAutoVerifySetting = async (
data: UpdateAutoVerifySettingRequest
): Promise<AutoVerifySettingResponse> => {
const response = await httpPostRequest<AutoVerifySettingResponse>(
APIUrlGenerator(API_ROUTES.UPDATE_AUTO_VERIFY_SETTING),
data
);
return response.data;
};

View File

@ -1,16 +0,0 @@
export type WalletType = "rial" | "gold18k";
export interface WalletCreditRequest {
user_id: number;
wallet_type: WalletType;
amount: number;
reason: string;
admin_note?: string;
}
export interface WalletCreditResponse {
transaction_id: number;
audit_log_id: number;
new_balance: number;
message: string;
}

View File

@ -1,9 +1,8 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/utils/query-key";
import toast from "react-hot-toast";
import { getWalletStatus, updateWalletStatus, creditWallet } from "./_requests";
import { getWalletStatus, updateWalletStatus } from "./_requests";
import { UpdateWalletStatusRequest } from "./_models";
import { WalletCreditRequest } from "./_credit-models";
export const useWalletStatus = () => {
return useQuery({
@ -29,20 +28,6 @@ export const useUpdateWalletStatus = () => {
});
};
export const useWalletCredit = () => {
return useMutation({
mutationKey: [QUERY_KEYS.WALLET_CREDIT],
mutationFn: (payload: WalletCreditRequest) => creditWallet(payload),
onSuccess: (data) => {
toast.success(data.message || "کیف پول با موفقیت شارژ شد");
},
onError: (error: any) => {
console.error("Error crediting wallet:", error);
toast.error(error?.message || "خطا در شارژ کیف پول");
},
});
};

View File

@ -1,7 +1,6 @@
import { httpGetRequest, httpPutRequest, httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { httpGetRequest, httpPutRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import { WalletStatusResponse, UpdateWalletStatusRequest, UpdateWalletStatusResponse } from "./_models";
import { WalletCreditRequest, WalletCreditResponse } from "./_credit-models";
export const getWalletStatus = async (): Promise<WalletStatusResponse> => {
const response = await httpGetRequest<WalletStatusResponse>(
@ -20,16 +19,6 @@ export const updateWalletStatus = async (
return response.data;
};
export const creditWallet = async (
payload: WalletCreditRequest
): Promise<WalletCreditResponse> => {
const response = await httpPostRequest<WalletCreditResponse>(
APIUrlGenerator(API_ROUTES.WALLET_CREDIT),
payload
);
return response.data;
};

View File

@ -1,180 +0,0 @@
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { Wallet, Plus, Loader2 } from 'lucide-react';
import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useWalletCredit } from '../core/_hooks';
import { WalletCreditRequest, WalletType } from '../core/_credit-models';
import { formatCurrency } from '@/utils/formatters';
import { persianToEnglish } from '@/utils/numberUtils';
const schema = yup.object({
user_id: yup.number().required('شناسه کاربر الزامی است').positive('شناسه کاربر باید عدد مثبت باشد'),
wallet_type: yup.string().oneOf(['rial', 'gold18k'], 'نوع کیف پول نامعتبر است').required('نوع کیف پول الزامی است'),
amount: yup.number().required('مبلغ الزامی است').positive('مبلغ باید عدد مثبت باشد'),
reason: yup.string().required('دلیل شارژ الزامی است').min(10, 'دلیل باید حداقل ۱۰ کاراکتر باشد'),
admin_note: yup.string().optional(),
});
type FormData = yup.InferType<typeof schema>;
const WalletCreditPage = () => {
const { mutate: creditWallet, isPending } = useWalletCredit();
const [successData, setSuccessData] = useState<{ new_balance: number; message: string } | null>(null);
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<FormData>({
resolver: yupResolver(schema),
defaultValues: {
wallet_type: 'rial',
},
});
const onSubmit = (data: FormData) => {
const payload: WalletCreditRequest = {
user_id: data.user_id,
wallet_type: data.wallet_type as WalletType,
amount: data.amount,
reason: data.reason,
admin_note: data.admin_note || undefined,
};
creditWallet(payload, {
onSuccess: (response) => {
setSuccessData({
new_balance: response.new_balance,
message: response.message,
});
reset();
setTimeout(() => setSuccessData(null), 5000);
},
});
};
return (
<PageContainer>
<PageTitle>شارژ کیف پول کاربر</PageTitle>
{successData && (
<div className="mb-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<p className="text-green-800 dark:text-green-200 font-medium mb-2">
{successData.message}
</p>
<p className="text-sm text-green-700 dark:text-green-300">
موجودی جدید: {formatCurrency(successData.new_balance)}
</p>
</div>
)}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
شناسه کاربر <span className="text-red-500">*</span>
</label>
<Input
type="number"
{...register('user_id', {
setValueAs: (value) => (value === '' ? undefined : Number(persianToEnglish(value))),
})}
error={errors.user_id?.message}
placeholder="مثلاً 52"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
نوع کیف پول <span className="text-red-500">*</span>
</label>
<select
{...register('wallet_type')}
className={`input ${errors.wallet_type ? 'border-red-500' : ''}`}
>
<option value="rial">ریال</option>
<option value="gold18k">طلا ۱۸ عیار</option>
</select>
{errors.wallet_type && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{errors.wallet_type.message}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
مبلغ (تومان) <span className="text-red-500">*</span>
</label>
<Input
type="text"
{...register('amount', {
setValueAs: (value) => (value === '' ? undefined : Number(persianToEnglish(value))),
})}
error={errors.amount?.message}
placeholder="مثلاً 1000000"
numeric
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
دلیل شارژ <span className="text-red-500">*</span>
</label>
<textarea
{...register('reason')}
rows={3}
className={`input resize-none ${errors.reason ? 'border-red-500' : ''}`}
placeholder="مثلاً: شارژ کیف پول کاربر به علت مرجوع کردن یک سفارش به مبلغ ۱ میلیون"
/>
{errors.reason && (
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
{errors.reason.message}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
یادداشت ادمین (اختیاری)
</label>
<textarea
{...register('admin_note')}
rows={2}
className="input resize-none"
placeholder="مثلاً: شارژ کیف طلا"
/>
</div>
<div className="flex items-center justify-end gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
type="button"
variant="secondary"
onClick={() => reset()}
disabled={isPending}
>
پاک کردن
</Button>
<Button
type="submit"
disabled={isPending}
loading={isPending}
>
<Plus className="h-4 w-4 ml-2" />
شارژ کیف پول
</Button>
</div>
</form>
</div>
</PageContainer>
);
};
export default WalletCreditPage;

View File

@ -143,25 +143,9 @@ export const QUERY_KEYS = {
GET_SALES_GROWTH_REPORT: "get_sales_growth_report",
GET_USER_REGISTRATION_GROWTH_REPORT: "get_user_registration_growth_report",
GET_SALES_BY_CATEGORY_REPORT: "get_sales_by_category_report",
GET_SALES_SUMMARY_REPORT: "get_sales_summary_report",
GET_PROFIT_LOSS_REPORT: "get_profit_loss_report",
GET_INVENTORY_VALUE_REPORT: "get_inventory_value_report",
GET_VARIANT_COMPARISON_REPORT: "get_variant_comparison_report",
// System Settings
GET_AUTO_VERIFY_SETTING: "get_auto_verify_setting",
UPDATE_AUTO_VERIFY_SETTING: "update_auto_verify_setting",
// Wallet Credit
WALLET_CREDIT: "wallet_credit",
// Product Comments
GET_PRODUCT_COMMENTS: "get_product_comments",
UPDATE_COMMENT_STATUS: "update_comment_status",
DELETE_COMMENT: "delete_comment",
// Admin Notifications
GET_ADMIN_NOTIFICATIONS: "get_admin_notifications",
GET_ADMIN_NOTIFICATIONS_UNREAD: "get_admin_notifications_unread",
GET_ADMIN_NOTIFICATIONS_COUNT: "get_admin_notifications_count",
};