Compare commits
2 Commits
3690a8c1f6
...
56a891e668
| Author | SHA1 | Date |
|---|---|---|
|
|
56a891e668 | |
|
|
fce23a41e2 |
34
src/App.tsx
34
src/App.tsx
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -178,6 +190,17 @@ const menuItems: MenuItem[] = [
|
||||||
title: 'مدیریت کیف پول',
|
title: 'مدیریت کیف پول',
|
||||||
icon: Wallet,
|
icon: Wallet,
|
||||||
path: '/wallet',
|
path: '/wallet',
|
||||||
|
exact: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'شارژ کیف پول',
|
||||||
|
icon: Wallet,
|
||||||
|
path: '/wallet/credit',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'تنظیمات سیستم',
|
||||||
|
icon: Settings,
|
||||||
|
path: '/system-settings',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -212,7 +235,14 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||||
if (child.exact) {
|
if (child.exact) {
|
||||||
return currentPath === child.path;
|
return currentPath === child.path;
|
||||||
}
|
}
|
||||||
return currentPath.startsWith(child.path);
|
// For non-exact paths, check if current path starts with child path
|
||||||
|
// but also ensure it's not a partial match (e.g., /wallet should not match /wallet/credit)
|
||||||
|
if (currentPath === child.path) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Only match if current path starts with child path AND has a slash after it
|
||||||
|
// This prevents /wallet from matching /wallet/credit
|
||||||
|
return currentPath.startsWith(child.path + '/');
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
@ -347,13 +377,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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
const IconComponent = report.icon;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={report.id}
|
key={report.id}
|
||||||
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
onClick={() => navigate(report.path)}
|
||||||
|
className="flex items-start p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg ml-4 flex-shrink-0">
|
||||||
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg ml-4">
|
<IconComponent className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
<FileText 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 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>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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[];
|
||||||
|
|
|
||||||
|
|
@ -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) : '-'}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
توضیحات
|
توضیحات
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,6 +140,15 @@ 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>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleApplyFilters}
|
||||||
|
disabled={!tempFilters.user_id || tempFilters.user_id === 0}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
اعمال فیلترها
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -128,6 +159,13 @@ const CustomerDiscountUsagePage = () => {
|
||||||
پاک کردن فیلترها
|
پاک کردن فیلترها
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 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">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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,6 +171,14 @@ 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>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleApplyFilters}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
اعمال فیلترها
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -168,6 +189,7 @@ const PaymentMethodsReportPage = () => {
|
||||||
پاک کردن فیلترها
|
پاک کردن فیلترها
|
||||||
</Button>
|
</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">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
گروهبندی بر اساس کاربر
|
گروهبندی بر اساس کاربر
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 || "خطا در بهروزرسانی تنظیمات");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 || "خطا در شارژ کیف پول");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue