feat: enhance application with new pages and improved UI components

- Added new pages for Wallet Credit, System Settings, Sales Summary, Profit Loss, Inventory Value, and Variant Comparison reports.
- Introduced Admin Notifications page and integrated it into the sidebar.
- Updated routing to accommodate new report pages and improved navigation structure.
- Enhanced sidebar with scrollbar styles for better usability.
- Implemented new input fields for gold price per gram and factory fee percentage in the Variant Manager.
- Refactored report handling in the Reports page to include new report types and improved filter functionality.

These changes significantly expand the application's reporting capabilities and improve user experience across various components.
This commit is contained in:
hosseintaromi 2026-02-07 13:08:37 +03:30
parent 3690a8c1f6
commit fce23a41e2
44 changed files with 3041 additions and 161 deletions

View File

@ -76,8 +76,12 @@ const IPGListPage = lazy(() => import('./pages/payment-ipg/ipg-list/IPGListPage'
// Payment Card Page // Payment Card Page
const CardFormPage = lazy(() => import('./pages/payment-card/card-form/CardFormPage')); const CardFormPage = lazy(() => import('./pages/payment-card/card-form/CardFormPage'));
// Wallet Page // Wallet Pages
const WalletListPage = lazy(() => import('./pages/wallet/wallet-list/WalletListPage')); 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 // Reports Pages
const DiscountUsageReportPage = lazy(() => import('./pages/reports/discount-statistics/discount-usage-report/DiscountUsageReportPage')); const DiscountUsageReportPage = lazy(() => import('./pages/reports/discount-statistics/discount-usage-report/DiscountUsageReportPage'));
@ -85,6 +89,21 @@ const CustomerDiscountUsagePage = lazy(() => import('./pages/reports/discount-st
const PaymentMethodsReportPage = lazy(() => import('./pages/reports/payment-statistics/payment-methods-report/PaymentMethodsReportPage')); 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')); 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 // Product Comments Page
const ProductCommentsListPage = lazy(() => import('./pages/products/comments/comments-list/ProductCommentsListPage')); const ProductCommentsListPage = lazy(() => import('./pages/products/comments/comments-list/ProductCommentsListPage'));
@ -185,14 +204,25 @@ const AppRoutes = () => {
{/* Payment Card Route */} {/* Payment Card Route */}
<Route path="payment-card" element={<CardFormPage />} /> <Route path="payment-card" element={<CardFormPage />} />
{/* Wallet Route */} {/* Wallet Routes */}
<Route path="wallet" element={<WalletListPage />} /> <Route path="wallet" element={<WalletListPage />} />
<Route path="wallet/credit" element={<WalletCreditPage />} />
{/* System Settings Route */}
<Route path="system-settings" element={<SystemSettingsPage />} />
{/* Reports Routes */} {/* Reports Routes */}
<Route path="reports/discount-usage" element={<DiscountUsageReportPage />} /> <Route path="reports/discount-usage" element={<DiscountUsageReportPage />} />
<Route path="reports/customer-discount-usage" element={<CustomerDiscountUsagePage />} /> <Route path="reports/customer-discount-usage" element={<CustomerDiscountUsagePage />} />
<Route path="reports/payment-methods" element={<PaymentMethodsReportPage />} /> <Route path="reports/payment-methods" element={<PaymentMethodsReportPage />} />
<Route path="reports/shipments-by-method" element={<ShipmentsByMethodReportPage />} /> <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> </Route>
</Routes> </Routes>
); );

View File

@ -1,4 +1,4 @@
import { Menu, Sun, Moon, Bell, User, LogOut } from 'lucide-react'; import { Menu, Sun, Moon, User, LogOut } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
@ -38,11 +38,6 @@ export const Header = ({ onMenuClick }: HeaderProps) => {
)} )}
</button> </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"> <div className="relative">
<button <button
onClick={() => setShowUserMenu(!showUserMenu)} onClick={() => setShowUserMenu(!showUserMenu)}

View File

@ -22,7 +22,11 @@ import {
Wallet, Wallet,
BarChart3, BarChart3,
FileText, FileText,
TrendingUp TrendingUp,
Bell,
DollarSign,
TrendingDown,
Warehouse
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { PermissionWrapper } from '../common/PermissionWrapper'; import { PermissionWrapper } from '../common/PermissionWrapper';
@ -59,21 +63,9 @@ const menuItems: MenuItem[] = [
path: '/discount-codes', path: '/discount-codes',
}, },
{ {
title: 'تیکت‌ها', title: 'اعلانات ادمین',
icon: MessageSquare, icon: Bell,
children: [ path: '/admin-notifications',
{
title: 'لیست تیکت‌ها',
icon: MessageSquare,
path: '/tickets',
exact: true,
},
{
title: 'تنظیمات تیکت',
icon: Sliders,
path: '/tickets/config',
},
]
}, },
{ {
title: 'پیام‌های تماس با ما', title: 'پیام‌های تماس با ما',
@ -130,6 +122,26 @@ const menuItems: MenuItem[] = [
icon: Truck, icon: Truck,
path: '/reports/shipments-by-method', 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',
},
] ]
}, },
{ {
@ -179,6 +191,16 @@ const menuItems: MenuItem[] = [
icon: Wallet, icon: Wallet,
path: '/wallet', path: '/wallet',
}, },
{
title: 'شارژ کیف پول',
icon: Wallet,
path: '/wallet/credit',
},
{
title: 'تنظیمات سیستم',
icon: Settings,
path: '/system-settings',
},
] ]
} }
]; ];
@ -347,13 +369,13 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
</SectionTitle> </SectionTitle>
</div> </div>
{/* Navigation */} {/* Navigation - scrollable */}
<nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto min-h-0"> <nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto overflow-x-hidden sidebar-nav min-h-0">
{menuItems.map(item => renderMenuItem(item))} {menuItems.map(item => renderMenuItem(item))}
</nav> </nav>
{/* User Info */} {/* User Info - fixed at bottom */}
<div className="border-t border-gray-200 dark:border-gray-700 p-4 flex-shrink-0"> <div className="border-t border-gray-200 dark:border-gray-700 p-4 flex-shrink-0 bg-white dark:bg-gray-800">
<div className="flex items-center space-x-3 space-x-reverse"> <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"> <div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
<span className="text-sm font-medium text-white"> <span className="text-sm font-medium text-white">

View File

@ -101,6 +101,8 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
const [feePercentageDisplay, setFeePercentageDisplay] = useState(variant?.fee_percentage?.toString() || ''); const [feePercentageDisplay, setFeePercentageDisplay] = useState(variant?.fee_percentage?.toString() || '');
const [profitPercentageDisplay, setProfitPercentageDisplay] = useState(variant?.profit_percentage?.toString() || ''); const [profitPercentageDisplay, setProfitPercentageDisplay] = useState(variant?.profit_percentage?.toString() || '');
const [taxPercentageDisplay, setTaxPercentageDisplay] = useState(variant?.tax_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 { mutateAsync: uploadFile } = useFileUpload();
const { mutate: deleteFile } = useFileDelete(); const { mutate: deleteFile } = useFileDelete();
@ -124,11 +126,17 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
if (variant?.tax_percentage !== undefined) { if (variant?.tax_percentage !== undefined) {
setTaxPercentageDisplay(variant.tax_percentage.toString()); 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 // Load variant attribute value if exists
if (variantAttributeName && variant?.attributes && variant.attributes[variantAttributeName]) { if (variantAttributeName && variant?.attributes && variant.attributes[variantAttributeName]) {
setVariantAttributeValue(variant.attributes[variantAttributeName].toString()); setVariantAttributeValue(variant.attributes[variantAttributeName].toString());
} }
}, [variant?.weight, variant?.fee_percentage, variant?.profit_percentage, variant?.tax_percentage, variant?.attributes, variantAttributeName]); }, [variant?.weight, variant?.fee_percentage, variant?.profit_percentage, variant?.tax_percentage, variant?.gold_price_per_gram, variant?.factory_fee_percentage, variant?.attributes, variantAttributeName]);
const handleInputChange = (field: keyof ProductVariantFormData, value: any) => { const handleInputChange = (field: keyof ProductVariantFormData, value: any) => {
if (typeof value === 'string') { if (typeof value === 'string') {
@ -309,6 +317,50 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
placeholder="مثال: ۱۲۰۰.۵" placeholder="مثال: ۱۲۰۰.۵"
/> />
</div> </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> </div>

View File

@ -160,9 +160,27 @@ export const API_ROUTES = {
SALES_GROWTH_REPORT: "reports/sales/growth", SALES_GROWTH_REPORT: "reports/sales/growth",
USER_REGISTRATION_GROWTH_REPORT: "reports/user-registration/growth", USER_REGISTRATION_GROWTH_REPORT: "reports/user-registration/growth",
SALES_BY_CATEGORY_REPORT: "reports/sales/by-category", 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 // Product Comments APIs
GET_PRODUCT_COMMENTS: "products/comments", GET_PRODUCT_COMMENTS: "products/comments",
UPDATE_COMMENT_STATUS: (commentId: string) => `products/comments/${commentId}/status`, UPDATE_COMMENT_STATUS: (commentId: string) => `products/comments/${commentId}/status`,
DELETE_COMMENT: (commentId: string) => `products/comments/${commentId}`, 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,4 +145,39 @@
min-height: 180px; 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,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { FileText, Download, TrendingUp, Users, ShoppingBag, DollarSign } from 'lucide-react'; import { useNavigate } from 'react-router-dom';
import { FileText, Download, TrendingUp, Users, ShoppingBag, DollarSign, Warehouse, TrendingDown, CreditCard, BarChart3 } from 'lucide-react';
import { Button } from '../components/ui/Button'; import { Button } from '../components/ui/Button';
import { BarChart } from '../components/charts/BarChart'; import { BarChart } from '../components/charts/BarChart';
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
@ -7,6 +8,7 @@ import { lazy, Suspense } from 'react';
const LineChart = lazy(() => import('../components/charts/LineChart').then(module => ({ default: module.LineChart }))); const LineChart = lazy(() => import('../components/charts/LineChart').then(module => ({ default: module.LineChart })));
export const Reports = () => { export const Reports = () => {
const navigate = useNavigate();
const [selectedPeriod, setSelectedPeriod] = useState('month'); const [selectedPeriod, setSelectedPeriod] = useState('month');
const salesData = [ const salesData = [
@ -27,38 +29,70 @@ export const Reports = () => {
{ name: 'شهریور', value: 320 }, { name: 'شهریور', value: 320 },
]; ];
const reports = [ const reportPages = [
{ {
id: 1, id: 1,
title: 'گزارش فروش ماهانه', title: 'گزارش خلاصه فروش',
description: 'گزارش کامل فروش محصولات در ماه گذشته', description: 'گزارش جامع فروش محصولات با تفکیک و آمار کامل',
type: 'فروش', icon: DollarSign,
date: '۱۴۰۲/۰۸/۳۰', path: '/reports/sales-summary',
format: 'PDF' type: 'فروش'
}, },
{ {
id: 2, id: 2,
title: 'گزارش کاربران جدید', title: 'گزارش ارزش موجودی',
description: 'آمار کاربران جدید عضو شده در سیستم', description: 'ارزش کل موجودی به تومان بر اساس قیمت لحظه‌ای',
type: 'کاربران', icon: Warehouse,
date: '۱۴۰۲/۰۸/۲۹', path: '/reports/inventory-value',
format: 'Excel' type: 'موجودی'
}, },
{ {
id: 3, id: 3,
title: 'گزارش موجودی انبار', title: 'گزارش روش‌های پرداخت',
description: 'وضعیت موجودی محصولات در انبار', description: 'آمار و گزارش روش‌های پرداخت استفاده شده',
type: 'انبار', icon: CreditCard,
date: '۱۴۰۲/۰۸/۲۸', path: '/reports/payment-methods',
format: 'PDF' type: 'پرداخت'
}, },
{ {
id: 4, id: 4,
title: 'گزارش درآمد روزانه', title: 'گزارش استفاده کاربر خاص از کدهای تخفیف',
description: 'جزئیات درآمد حاصل از فروش در ۳۰ روز گذشته', description: 'گزارش استفاده یک کاربر خاص از کدهای تخفیف',
type: 'مالی', icon: Users,
date: '۱۴۰۲/۰۸/۲۷', path: '/reports/customer-discount-usage',
format: 'Excel' 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: 'ارسال'
} }
]; ];
@ -175,50 +209,36 @@ export const Reports = () => {
<div className="bg-white dark:bg-gray-800 rounded-lg shadow"> <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"> <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 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
گزارشهای اخیر گزارشهای موجود
</h3> </h3>
</div> </div>
<div className="p-6"> <div className="p-6">
<div className="space-y-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{reports.map((report) => ( {reportPages.map((report) => {
<div const IconComponent = report.icon;
key={report.id} return (
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
> key={report.id}
<div className="flex items-center"> onClick={() => navigate(report.path)}
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg ml-4"> 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"
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" /> >
<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> </div>
<div> <div className="flex-1">
<h4 className="font-medium text-gray-900 dark:text-gray-100"> <h4 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
{report.title} {report.title}
</h4> </h4>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600 dark:text-gray-400">
{report.description} {report.description}
</p> </p>
<div className="flex items-center mt-1 space-x-4"> <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">
<span className="text-xs text-gray-500 dark:text-gray-500"> {report.type}
نوع: {report.type} </span>
</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>
</div> </div>
<Button );
size="sm" })}
variant="secondary"
onClick={() => handleDownloadReport(report.id)}
>
<Download className="h-4 w-4 ml-2" />
دانلود
</Button>
</div>
))}
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,58 @@
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

@ -0,0 +1,44 @@
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

@ -0,0 +1,59 @@
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

@ -0,0 +1,251 @@
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,6 +110,7 @@ export interface Order {
national_code?: string; national_code?: string;
verified: boolean; verified: boolean;
avatar?: string; avatar?: string;
is_deleted?: boolean;
}; };
payment_status?: PaymentStatus; payment_status?: PaymentStatus;
payments?: OrderPaymentRecord[]; payments?: OrderPaymentRecord[];

View File

@ -179,8 +179,13 @@ const OrdersListPage = () => {
align: 'right', align: 'right',
render: (_val, row: any) => ( render: (_val, row: any) => (
<div className="text-right"> <div className="text-right">
<div className="font-medium"> <div className="font-medium flex items-center gap-2">
{(row.user?.first_name || row.customer?.first_name || 'نامشخص')} {(row.user?.last_name || row.customer?.last_name || '')} {(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>
<div className="text-gray-500 dark:text-gray-400" dir="ltr" style={{ direction: 'ltr' }}> <div className="text-gray-500 dark:text-gray-400" dir="ltr" style={{ direction: 'ltr' }}>
{row.user?.phone_number ? englishToPersian(row.user.phone_number) : '-'} {row.user?.phone_number ? englishToPersian(row.user.phone_number) : '-'}

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService"; import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes"; import { API_ROUTES } from "@/constant/routes";
import { import {
DiscountUsageFilters, DiscountUsageFilters,
@ -10,9 +10,19 @@ import {
export const getDiscountUsageReport = async ( export const getDiscountUsageReport = async (
filters: DiscountUsageFilters filters: DiscountUsageFilters
): Promise<DiscountUsageResponse> => { ): Promise<DiscountUsageResponse> => {
const response = await httpPostRequest<DiscountUsageResponse>( const queryParams: Record<string, string | number | boolean> = {};
APIUrlGenerator(API_ROUTES.DISCOUNT_USAGE_REPORT),
filters 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)
); );
return response.data; return response.data;
}; };
@ -20,9 +30,18 @@ export const getDiscountUsageReport = async (
export const getCustomerDiscountUsageReport = async ( export const getCustomerDiscountUsageReport = async (
filters: CustomerDiscountUsageFilters filters: CustomerDiscountUsageFilters
): Promise<CustomerDiscountUsageResponse> => { ): Promise<CustomerDiscountUsageResponse> => {
const response = await httpPostRequest<CustomerDiscountUsageResponse>( const queryParams: Record<string, string | number> = {};
APIUrlGenerator(API_ROUTES.CUSTOMER_DISCOUNT_USAGE_REPORT),
filters 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)
); );
return response.data; return response.data;
}; };

View File

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

View File

@ -0,0 +1,10 @@
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

@ -0,0 +1,19 @@
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

@ -0,0 +1,10 @@
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

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

View File

@ -0,0 +1,11 @@
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

@ -0,0 +1,45 @@
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

@ -0,0 +1,23 @@
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

@ -0,0 +1,432 @@
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

@ -0,0 +1,11 @@
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

@ -0,0 +1,61 @@
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

@ -0,0 +1,29 @@
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

@ -0,0 +1,440 @@
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

@ -0,0 +1,11 @@
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

@ -0,0 +1,53 @@
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

@ -0,0 +1,27 @@
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

@ -0,0 +1,395 @@
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

@ -0,0 +1,84 @@
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

@ -0,0 +1,40 @@
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

@ -0,0 +1,19 @@
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

@ -0,0 +1,23 @@
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

@ -0,0 +1,16 @@
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,8 +1,9 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/utils/query-key"; import { QUERY_KEYS } from "@/utils/query-key";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { getWalletStatus, updateWalletStatus } from "./_requests"; import { getWalletStatus, updateWalletStatus, creditWallet } from "./_requests";
import { UpdateWalletStatusRequest } from "./_models"; import { UpdateWalletStatusRequest } from "./_models";
import { WalletCreditRequest } from "./_credit-models";
export const useWalletStatus = () => { export const useWalletStatus = () => {
return useQuery({ return useQuery({
@ -28,6 +29,20 @@ 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,6 +1,7 @@
import { httpGetRequest, httpPutRequest, APIUrlGenerator } from "@/utils/baseHttpService"; import { httpGetRequest, httpPutRequest, httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes"; import { API_ROUTES } from "@/constant/routes";
import { WalletStatusResponse, UpdateWalletStatusRequest, UpdateWalletStatusResponse } from "./_models"; import { WalletStatusResponse, UpdateWalletStatusRequest, UpdateWalletStatusResponse } from "./_models";
import { WalletCreditRequest, WalletCreditResponse } from "./_credit-models";
export const getWalletStatus = async (): Promise<WalletStatusResponse> => { export const getWalletStatus = async (): Promise<WalletStatusResponse> => {
const response = await httpGetRequest<WalletStatusResponse>( const response = await httpGetRequest<WalletStatusResponse>(
@ -19,6 +20,16 @@ export const updateWalletStatus = async (
return response.data; 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

@ -0,0 +1,180 @@
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,9 +143,25 @@ export const QUERY_KEYS = {
GET_SALES_GROWTH_REPORT: "get_sales_growth_report", GET_SALES_GROWTH_REPORT: "get_sales_growth_report",
GET_USER_REGISTRATION_GROWTH_REPORT: "get_user_registration_growth_report", GET_USER_REGISTRATION_GROWTH_REPORT: "get_user_registration_growth_report",
GET_SALES_BY_CATEGORY_REPORT: "get_sales_by_category_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 // Product Comments
GET_PRODUCT_COMMENTS: "get_product_comments", GET_PRODUCT_COMMENTS: "get_product_comments",
UPDATE_COMMENT_STATUS: "update_comment_status", UPDATE_COMMENT_STATUS: "update_comment_status",
DELETE_COMMENT: "delete_comment", 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",
}; };