Compare commits
No commits in common. "56a891e6685336979a8c0c61f84ec193681472c0" and "3690a8c1f62cdfe63c1b85a8a39180a466b41c9a" have entirely different histories.
56a891e668
...
3690a8c1f6
34
src/App.tsx
34
src/App.tsx
|
|
@ -76,12 +76,8 @@ const IPGListPage = lazy(() => import('./pages/payment-ipg/ipg-list/IPGListPage'
|
|||
// Payment Card Page
|
||||
const CardFormPage = lazy(() => import('./pages/payment-card/card-form/CardFormPage'));
|
||||
|
||||
// Wallet Pages
|
||||
// Wallet Page
|
||||
const WalletListPage = lazy(() => import('./pages/wallet/wallet-list/WalletListPage'));
|
||||
const WalletCreditPage = lazy(() => import('./pages/wallet/wallet-credit/WalletCreditPage'));
|
||||
|
||||
// System Settings Page
|
||||
const SystemSettingsPage = lazy(() => import('./pages/system-settings/SystemSettingsPage'));
|
||||
|
||||
// Reports Pages
|
||||
const DiscountUsageReportPage = lazy(() => import('./pages/reports/discount-statistics/discount-usage-report/DiscountUsageReportPage'));
|
||||
|
|
@ -89,21 +85,6 @@ const CustomerDiscountUsagePage = lazy(() => import('./pages/reports/discount-st
|
|||
const PaymentMethodsReportPage = lazy(() => import('./pages/reports/payment-statistics/payment-methods-report/PaymentMethodsReportPage'));
|
||||
const ShipmentsByMethodReportPage = lazy(() => import('./pages/reports/shipment-statistics/shipments-by-method-report/ShipmentsByMethodReportPage'));
|
||||
|
||||
// Sales Summary Report Page
|
||||
const SalesSummaryReportPage = lazy(() => import('./pages/reports/sales-summary/sales-summary-report/SalesSummaryReportPage'));
|
||||
|
||||
// Profit Loss Report Page
|
||||
const ProfitLossReportPage = lazy(() => import('./pages/reports/profit-loss/profit-loss-report/ProfitLossReportPage'));
|
||||
|
||||
// Inventory Value Report Page
|
||||
const InventoryValueReportPage = lazy(() => import('./pages/reports/inventory-value/inventory-value-report/InventoryValueReportPage'));
|
||||
|
||||
// Variant Comparison Report Page
|
||||
const VariantComparisonReportPage = lazy(() => import('./pages/reports/variant-comparison/variant-comparison-report/VariantComparisonReportPage'));
|
||||
|
||||
// Admin Notifications Page
|
||||
const AdminNotificationsListPage = lazy(() => import('./pages/admin-notifications/notifications-list/AdminNotificationsListPage'));
|
||||
|
||||
// Product Comments Page
|
||||
const ProductCommentsListPage = lazy(() => import('./pages/products/comments/comments-list/ProductCommentsListPage'));
|
||||
|
||||
|
|
@ -204,25 +185,14 @@ const AppRoutes = () => {
|
|||
{/* Payment Card Route */}
|
||||
<Route path="payment-card" element={<CardFormPage />} />
|
||||
|
||||
{/* Wallet Routes */}
|
||||
{/* Wallet Route */}
|
||||
<Route path="wallet" element={<WalletListPage />} />
|
||||
<Route path="wallet/credit" element={<WalletCreditPage />} />
|
||||
|
||||
{/* System Settings Route */}
|
||||
<Route path="system-settings" element={<SystemSettingsPage />} />
|
||||
|
||||
{/* Reports Routes */}
|
||||
<Route path="reports/discount-usage" element={<DiscountUsageReportPage />} />
|
||||
<Route path="reports/customer-discount-usage" element={<CustomerDiscountUsagePage />} />
|
||||
<Route path="reports/payment-methods" element={<PaymentMethodsReportPage />} />
|
||||
<Route path="reports/shipments-by-method" element={<ShipmentsByMethodReportPage />} />
|
||||
<Route path="reports/sales-summary" element={<SalesSummaryReportPage />} />
|
||||
<Route path="reports/profit-loss" element={<ProfitLossReportPage />} />
|
||||
<Route path="reports/inventory-value" element={<InventoryValueReportPage />} />
|
||||
<Route path="reports/variant-comparison" element={<VariantComparisonReportPage />} />
|
||||
|
||||
{/* Admin Notifications Route */}
|
||||
<Route path="admin-notifications" element={<AdminNotificationsListPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Menu, Sun, Moon, User, LogOut } from 'lucide-react';
|
||||
import { Menu, Sun, Moon, Bell, User, LogOut } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
|
@ -38,6 +38,11 @@ export const Header = ({ onMenuClick }: HeaderProps) => {
|
|||
)}
|
||||
</button>
|
||||
|
||||
<button className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors relative">
|
||||
<Bell className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
<span className="absolute top-0 left-0 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
|
|
|
|||
|
|
@ -22,11 +22,7 @@ import {
|
|||
Wallet,
|
||||
BarChart3,
|
||||
FileText,
|
||||
TrendingUp,
|
||||
Bell,
|
||||
DollarSign,
|
||||
TrendingDown,
|
||||
Warehouse
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { PermissionWrapper } from '../common/PermissionWrapper';
|
||||
|
|
@ -63,9 +59,21 @@ const menuItems: MenuItem[] = [
|
|||
path: '/discount-codes',
|
||||
},
|
||||
{
|
||||
title: 'اعلانات ادمین',
|
||||
icon: Bell,
|
||||
path: '/admin-notifications',
|
||||
title: 'تیکتها',
|
||||
icon: MessageSquare,
|
||||
children: [
|
||||
{
|
||||
title: 'لیست تیکتها',
|
||||
icon: MessageSquare,
|
||||
path: '/tickets',
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
title: 'تنظیمات تیکت',
|
||||
icon: Sliders,
|
||||
path: '/tickets/config',
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'پیامهای تماس با ما',
|
||||
|
|
@ -122,26 +130,6 @@ const menuItems: MenuItem[] = [
|
|||
icon: Truck,
|
||||
path: '/reports/shipments-by-method',
|
||||
},
|
||||
{
|
||||
title: 'گزارش خلاصه فروش',
|
||||
icon: DollarSign,
|
||||
path: '/reports/sales-summary',
|
||||
},
|
||||
{
|
||||
title: 'گزارش سود و زیان',
|
||||
icon: TrendingDown,
|
||||
path: '/reports/profit-loss',
|
||||
},
|
||||
{
|
||||
title: 'گزارش ارزش موجودی',
|
||||
icon: Warehouse,
|
||||
path: '/reports/inventory-value',
|
||||
},
|
||||
{
|
||||
title: 'گزارش مقایسه Variant',
|
||||
icon: TrendingUp,
|
||||
path: '/reports/variant-comparison',
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -190,17 +178,6 @@ const menuItems: MenuItem[] = [
|
|||
title: 'مدیریت کیف پول',
|
||||
icon: Wallet,
|
||||
path: '/wallet',
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
title: 'شارژ کیف پول',
|
||||
icon: Wallet,
|
||||
path: '/wallet/credit',
|
||||
},
|
||||
{
|
||||
title: 'تنظیمات سیستم',
|
||||
icon: Settings,
|
||||
path: '/system-settings',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
@ -235,14 +212,7 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||
if (child.exact) {
|
||||
return currentPath === child.path;
|
||||
}
|
||||
// For non-exact paths, check if current path starts with child path
|
||||
// but also ensure it's not a partial match (e.g., /wallet should not match /wallet/credit)
|
||||
if (currentPath === child.path) {
|
||||
return true;
|
||||
}
|
||||
// Only match if current path starts with child path AND has a slash after it
|
||||
// This prevents /wallet from matching /wallet/credit
|
||||
return currentPath.startsWith(child.path + '/');
|
||||
return currentPath.startsWith(child.path);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
|
@ -377,13 +347,13 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||
</SectionTitle>
|
||||
</div>
|
||||
|
||||
{/* Navigation - scrollable */}
|
||||
<nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto overflow-x-hidden sidebar-nav min-h-0">
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto min-h-0">
|
||||
{menuItems.map(item => renderMenuItem(item))}
|
||||
</nav>
|
||||
|
||||
{/* User Info - fixed at bottom */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 flex-shrink-0 bg-white dark:bg-gray-800">
|
||||
{/* User Info */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 flex-shrink-0">
|
||||
<div className="flex items-center space-x-3 space-x-reverse">
|
||||
<div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-white">
|
||||
|
|
|
|||
|
|
@ -101,8 +101,6 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
const [feePercentageDisplay, setFeePercentageDisplay] = useState(variant?.fee_percentage?.toString() || '');
|
||||
const [profitPercentageDisplay, setProfitPercentageDisplay] = useState(variant?.profit_percentage?.toString() || '');
|
||||
const [taxPercentageDisplay, setTaxPercentageDisplay] = useState(variant?.tax_percentage?.toString() || '');
|
||||
const [goldPricePerGramDisplay, setGoldPricePerGramDisplay] = useState(variant?.gold_price_per_gram?.toString() || '');
|
||||
const [factoryFeePercentageDisplay, setFactoryFeePercentageDisplay] = useState(variant?.factory_fee_percentage?.toString() || '');
|
||||
|
||||
const { mutateAsync: uploadFile } = useFileUpload();
|
||||
const { mutate: deleteFile } = useFileDelete();
|
||||
|
|
@ -126,17 +124,11 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
if (variant?.tax_percentage !== undefined) {
|
||||
setTaxPercentageDisplay(variant.tax_percentage.toString());
|
||||
}
|
||||
if (variant?.gold_price_per_gram !== undefined) {
|
||||
setGoldPricePerGramDisplay(variant.gold_price_per_gram.toString());
|
||||
}
|
||||
if (variant?.factory_fee_percentage !== undefined) {
|
||||
setFactoryFeePercentageDisplay(variant.factory_fee_percentage.toString());
|
||||
}
|
||||
// Load variant attribute value if exists
|
||||
if (variantAttributeName && variant?.attributes && variant.attributes[variantAttributeName]) {
|
||||
setVariantAttributeValue(variant.attributes[variantAttributeName].toString());
|
||||
}
|
||||
}, [variant?.weight, variant?.fee_percentage, variant?.profit_percentage, variant?.tax_percentage, variant?.gold_price_per_gram, variant?.factory_fee_percentage, variant?.attributes, variantAttributeName]);
|
||||
}, [variant?.weight, variant?.fee_percentage, variant?.profit_percentage, variant?.tax_percentage, variant?.attributes, variantAttributeName]);
|
||||
|
||||
const handleInputChange = (field: keyof ProductVariantFormData, value: any) => {
|
||||
if (typeof value === 'string') {
|
||||
|
|
@ -317,50 +309,6 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
placeholder="مثال: ۱۲۰۰.۵"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
قیمت هر گرم طلا (تومان)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={goldPricePerGramDisplay}
|
||||
onChange={(e) => {
|
||||
const converted = persianToEnglish(e.target.value);
|
||||
setGoldPricePerGramDisplay(converted);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const converted = persianToEnglish(e.target.value);
|
||||
const numValue = parseFloat(converted) || undefined;
|
||||
handleInputChange('gold_price_per_gram', numValue);
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="مثال: ۸۵۵۰۸۱۶"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
درصد اجرت کارخانه
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={factoryFeePercentageDisplay}
|
||||
onChange={(e) => {
|
||||
const converted = persianToEnglish(e.target.value);
|
||||
setFactoryFeePercentageDisplay(converted);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const converted = persianToEnglish(e.target.value);
|
||||
const numValue = parseFloat(converted) || undefined;
|
||||
handleInputChange('factory_fee_percentage', numValue);
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="مثال: ۲"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -160,27 +160,9 @@ export const API_ROUTES = {
|
|||
SALES_GROWTH_REPORT: "reports/sales/growth",
|
||||
USER_REGISTRATION_GROWTH_REPORT: "reports/user-registration/growth",
|
||||
SALES_BY_CATEGORY_REPORT: "reports/sales/by-category",
|
||||
SALES_SUMMARY_REPORT: "reports/sales/summary",
|
||||
PROFIT_LOSS_REPORT: "reports/profit-loss",
|
||||
INVENTORY_VALUE_REPORT: "reports/inventory/value",
|
||||
VARIANT_COMPARISON_REPORT: "reports/variants/comparison",
|
||||
|
||||
// Product Comments APIs
|
||||
GET_PRODUCT_COMMENTS: "products/comments",
|
||||
UPDATE_COMMENT_STATUS: (commentId: string) => `products/comments/${commentId}/status`,
|
||||
DELETE_COMMENT: (commentId: string) => `products/comments/${commentId}`,
|
||||
|
||||
// Admin Notifications APIs
|
||||
GET_ADMIN_NOTIFICATIONS: "notifications",
|
||||
GET_ADMIN_NOTIFICATIONS_UNREAD: "notifications/unread",
|
||||
GET_ADMIN_NOTIFICATIONS_COUNT: "notifications/count",
|
||||
MARK_NOTIFICATION_READ: (id: string) => `notifications/${id}/read`,
|
||||
MARK_ALL_NOTIFICATIONS_READ: "notifications/read-all",
|
||||
|
||||
// System Settings APIs
|
||||
GET_AUTO_VERIFY_SETTING: "settings/auto-verify-new-users",
|
||||
UPDATE_AUTO_VERIFY_SETTING: "settings/auto-verify-new-users",
|
||||
|
||||
// Wallet Credit APIs
|
||||
WALLET_CREDIT: "wallet/credit",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -145,39 +145,4 @@
|
|||
min-height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar scrollbar styles */
|
||||
.sidebar-nav {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d1d5db transparent;
|
||||
}
|
||||
|
||||
.sidebar-nav::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar-nav::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar-nav::-webkit-scrollbar-thumb {
|
||||
background-color: #d1d5db;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sidebar-nav::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #9ca3af;
|
||||
}
|
||||
|
||||
.dark .sidebar-nav {
|
||||
scrollbar-color: #4b5563 transparent;
|
||||
}
|
||||
|
||||
.dark .sidebar-nav::-webkit-scrollbar-thumb {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
.dark .sidebar-nav::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FileText, Download, TrendingUp, Users, ShoppingBag, DollarSign, Warehouse, TrendingDown, CreditCard, BarChart3 } from 'lucide-react';
|
||||
import { FileText, Download, TrendingUp, Users, ShoppingBag, DollarSign } from 'lucide-react';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { BarChart } from '../components/charts/BarChart';
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
|
@ -8,7 +7,6 @@ import { lazy, Suspense } from 'react';
|
|||
const LineChart = lazy(() => import('../components/charts/LineChart').then(module => ({ default: module.LineChart })));
|
||||
|
||||
export const Reports = () => {
|
||||
const navigate = useNavigate();
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('month');
|
||||
|
||||
const salesData = [
|
||||
|
|
@ -29,70 +27,38 @@ export const Reports = () => {
|
|||
{ name: 'شهریور', value: 320 },
|
||||
];
|
||||
|
||||
const reportPages = [
|
||||
const reports = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'گزارش خلاصه فروش',
|
||||
description: 'گزارش جامع فروش محصولات با تفکیک و آمار کامل',
|
||||
icon: DollarSign,
|
||||
path: '/reports/sales-summary',
|
||||
type: 'فروش'
|
||||
title: 'گزارش فروش ماهانه',
|
||||
description: 'گزارش کامل فروش محصولات در ماه گذشته',
|
||||
type: 'فروش',
|
||||
date: '۱۴۰۲/۰۸/۳۰',
|
||||
format: 'PDF'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'گزارش ارزش موجودی',
|
||||
description: 'ارزش کل موجودی به تومان بر اساس قیمت لحظهای',
|
||||
icon: Warehouse,
|
||||
path: '/reports/inventory-value',
|
||||
type: 'موجودی'
|
||||
title: 'گزارش کاربران جدید',
|
||||
description: 'آمار کاربران جدید عضو شده در سیستم',
|
||||
type: 'کاربران',
|
||||
date: '۱۴۰۲/۰۸/۲۹',
|
||||
format: 'Excel'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'گزارش روشهای پرداخت',
|
||||
description: 'آمار و گزارش روشهای پرداخت استفاده شده',
|
||||
icon: CreditCard,
|
||||
path: '/reports/payment-methods',
|
||||
type: 'پرداخت'
|
||||
title: 'گزارش موجودی انبار',
|
||||
description: 'وضعیت موجودی محصولات در انبار',
|
||||
type: 'انبار',
|
||||
date: '۱۴۰۲/۰۸/۲۸',
|
||||
format: 'PDF'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'گزارش استفاده کاربر خاص از کدهای تخفیف',
|
||||
description: 'گزارش استفاده یک کاربر خاص از کدهای تخفیف',
|
||||
icon: Users,
|
||||
path: '/reports/customer-discount-usage',
|
||||
type: 'تخفیف'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'گزارش مقایسه Variant',
|
||||
description: 'مقایسه درصد اجرت کارخانه و درصد اجرت کاربر',
|
||||
icon: TrendingUp,
|
||||
path: '/reports/variant-comparison',
|
||||
type: 'مقایسه'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'گزارش سود و زیان',
|
||||
description: 'گزارش سود یا زیان محقق شده',
|
||||
icon: TrendingDown,
|
||||
path: '/reports/profit-loss',
|
||||
type: 'مالی'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: 'گزارش کدهای تخفیف',
|
||||
description: 'گزارش استفاده از کدهای تخفیف',
|
||||
icon: BarChart3,
|
||||
path: '/reports/discount-usage',
|
||||
type: 'تخفیف'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: 'گزارش ارسالها',
|
||||
description: 'گزارش ارسالها بر اساس روش ارسال',
|
||||
icon: ShoppingBag,
|
||||
path: '/reports/shipments-by-method',
|
||||
type: 'ارسال'
|
||||
title: 'گزارش درآمد روزانه',
|
||||
description: 'جزئیات درآمد حاصل از فروش در ۳۰ روز گذشته',
|
||||
type: 'مالی',
|
||||
date: '۱۴۰۲/۰۸/۲۷',
|
||||
format: 'Excel'
|
||||
}
|
||||
];
|
||||
|
||||
|
|
@ -209,36 +175,50 @@ export const Reports = () => {
|
|||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
گزارشهای موجود
|
||||
گزارشهای اخیر
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{reportPages.map((report) => {
|
||||
const IconComponent = report.icon;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{reports.map((report) => (
|
||||
<div
|
||||
key={report.id}
|
||||
onClick={() => navigate(report.path)}
|
||||
className="flex items-start p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
|
||||
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg ml-4 flex-shrink-0">
|
||||
<IconComponent className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg ml-4">
|
||||
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{report.title}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{report.description}
|
||||
</p>
|
||||
<span className="inline-block mt-2 text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
|
||||
{report.type}
|
||||
<div className="flex items-center mt-1 space-x-4">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-500">
|
||||
نوع: {report.type}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-500">
|
||||
تاریخ: {report.date}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-500">
|
||||
فرمت: {report.format}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleDownloadReport(report.id)}
|
||||
>
|
||||
<Download className="h-4 w-4 ml-2" />
|
||||
دانلود
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { QUERY_KEYS } from "@/utils/query-key";
|
||||
import {
|
||||
getAdminNotifications,
|
||||
getAdminNotificationsUnread,
|
||||
getAdminNotificationsCount,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
} from "./_requests";
|
||||
import { AdminNotificationsFilters } from "./_models";
|
||||
|
||||
export const useAdminNotifications = (filters?: AdminNotificationsFilters) => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS, filters],
|
||||
queryFn: () => getAdminNotifications(filters),
|
||||
});
|
||||
};
|
||||
|
||||
export const useAdminNotificationsUnread = (limit?: number) => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS_UNREAD, limit],
|
||||
queryFn: () => getAdminNotificationsUnread(limit),
|
||||
});
|
||||
};
|
||||
|
||||
export const useAdminNotificationsCount = () => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS_COUNT],
|
||||
queryFn: () => getAdminNotificationsCount(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
};
|
||||
|
||||
export const useMarkNotificationRead = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (notificationId: number) => markNotificationRead(notificationId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS] });
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS_UNREAD] });
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS_COUNT] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useMarkAllNotificationsRead = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => markAllNotificationsRead(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS] });
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS_UNREAD] });
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_ADMIN_NOTIFICATIONS_COUNT] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
export interface AdminNotification {
|
||||
id: number;
|
||||
admin_user_id: number;
|
||||
title: string;
|
||||
message: string;
|
||||
metadata?: {
|
||||
alert_type?: string;
|
||||
error_msg?: string;
|
||||
event_id?: string;
|
||||
invoice_id?: string;
|
||||
payment_id?: string;
|
||||
retry_count?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AdminNotificationsResponse {
|
||||
notifications: AdminNotification[];
|
||||
unread_count: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface AdminNotificationsUnreadResponse {
|
||||
notifications: AdminNotification[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AdminNotificationsCountResponse {
|
||||
unread_count: number;
|
||||
}
|
||||
|
||||
export interface MarkNotificationReadResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AdminNotificationsFilters {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import { httpGetRequest, httpPutRequest, APIUrlGenerator } from "@/utils/baseHttpService";
|
||||
import { API_ROUTES } from "@/constant/routes";
|
||||
import {
|
||||
AdminNotificationsResponse,
|
||||
AdminNotificationsUnreadResponse,
|
||||
AdminNotificationsCountResponse,
|
||||
MarkNotificationReadResponse,
|
||||
AdminNotificationsFilters,
|
||||
} from "./_models";
|
||||
|
||||
export const getAdminNotifications = async (
|
||||
filters?: AdminNotificationsFilters
|
||||
): Promise<AdminNotificationsResponse> => {
|
||||
const queryParams: Record<string, string | number> = {};
|
||||
|
||||
if (filters?.offset !== undefined) queryParams.offset = filters.offset;
|
||||
if (filters?.limit !== undefined) queryParams.limit = filters.limit;
|
||||
|
||||
const response = await httpGetRequest<AdminNotificationsResponse>(
|
||||
APIUrlGenerator(API_ROUTES.GET_ADMIN_NOTIFICATIONS, queryParams)
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getAdminNotificationsUnread = async (
|
||||
limit?: number
|
||||
): Promise<AdminNotificationsUnreadResponse> => {
|
||||
const queryParams: Record<string, number> = {};
|
||||
|
||||
if (limit !== undefined) queryParams.limit = limit;
|
||||
|
||||
const response = await httpGetRequest<AdminNotificationsUnreadResponse>(
|
||||
APIUrlGenerator(API_ROUTES.GET_ADMIN_NOTIFICATIONS_UNREAD, queryParams)
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getAdminNotificationsCount = async (): Promise<AdminNotificationsCountResponse> => {
|
||||
const response = await httpGetRequest<AdminNotificationsCountResponse>(
|
||||
APIUrlGenerator(API_ROUTES.GET_ADMIN_NOTIFICATIONS_COUNT)
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const markNotificationRead = async (
|
||||
notificationId: number
|
||||
): Promise<MarkNotificationReadResponse> => {
|
||||
const response = await httpPutRequest<MarkNotificationReadResponse>(
|
||||
APIUrlGenerator(API_ROUTES.MARK_NOTIFICATION_READ(notificationId.toString()))
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const markAllNotificationsRead = async (): Promise<MarkNotificationReadResponse> => {
|
||||
const response = await httpPutRequest<MarkNotificationReadResponse>(
|
||||
APIUrlGenerator(API_ROUTES.MARK_ALL_NOTIFICATIONS_READ)
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { Bell, BellOff, Check, CheckCheck, AlertCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Pagination } from '@/components/ui/Pagination';
|
||||
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
||||
import { useAdminNotifications, useMarkNotificationRead, useMarkAllNotificationsRead } from '../core/_hooks';
|
||||
import { AdminNotification } from '../core/_models';
|
||||
import { formatDateTime } from '@/utils/formatters';
|
||||
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
const AdminNotificationsListPage = () => {
|
||||
const [filters, setFilters] = useState({ offset: 0, limit: 20 });
|
||||
const [filterType, setFilterType] = useState<'all' | 'unread'>('all');
|
||||
|
||||
const { data, isLoading, error } = useAdminNotifications(filters);
|
||||
const { mutate: markRead, isPending: isMarkingRead } = useMarkNotificationRead();
|
||||
const { mutate: markAllRead, isPending: isMarkingAllRead } = useMarkAllNotificationsRead();
|
||||
|
||||
const notifications = data?.notifications || [];
|
||||
const unreadCount = data?.unread_count || 0;
|
||||
const totalPages = Math.ceil((data?.notifications?.length || 0) / filters.limit);
|
||||
|
||||
const filteredNotifications = filterType === 'unread'
|
||||
? notifications.filter(n => !n.is_read)
|
||||
: notifications;
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
offset: (page - 1) * prev.limit,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMarkAsRead = (notificationId: number) => {
|
||||
markRead(notificationId, {
|
||||
onSuccess: () => {
|
||||
toast.success('نوتیفیکیشن به عنوان خوانده شده علامتگذاری شد');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('خطا در علامتگذاری نوتیفیکیشن');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleMarkAllAsRead = () => {
|
||||
markAllRead(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success('همه نوتیفیکیشنها به عنوان خوانده شده علامتگذاری شدند');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('خطا در علامتگذاری نوتیفیکیشنها');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getNotificationIcon = (notification: AdminNotification) => {
|
||||
if (notification.metadata?.alert_type === 'failed_order_creation') {
|
||||
return <AlertCircle className="h-5 w-5 text-red-600" />;
|
||||
}
|
||||
return <Bell className="h-5 w-5 text-blue-600" />;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<ReportSkeleton />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="text-center py-12">
|
||||
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">خطا در بارگذاری نوتیفیکیشنها</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<PageTitle>اعلانات ادمین</PageTitle>
|
||||
<div className="flex items-center gap-4">
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={isMarkingAllRead}
|
||||
>
|
||||
<CheckCheck className="h-4 w-4 ml-2" />
|
||||
همه را خوانده شده علامت بزن
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<Bell className="h-8 w-8 text-blue-600" />
|
||||
<div className="mr-3">
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">کل اعلانات</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{notifications.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<BellOff className="h-8 w-8 text-red-600" />
|
||||
<div className="mr-3">
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">خوانده نشده</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{unreadCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow">
|
||||
<div className="flex items-center">
|
||||
<Check className="h-8 w-8 text-green-600" />
|
||||
<div className="mr-3">
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">خوانده شده</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{notifications.length - unreadCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
لیست اعلانات
|
||||
</h3>
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value as 'all' | 'unread')}
|
||||
className="input min-w-[150px]"
|
||||
>
|
||||
<option value="all">همه اعلانات</option>
|
||||
<option value="unread">خوانده نشده</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
{filteredNotifications.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Bell className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">هیچ اعلانی یافت نشد</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredNotifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`p-4 border-r-4 ${
|
||||
notification.is_read
|
||||
? 'border-r-gray-300 bg-gray-50 dark:bg-gray-700'
|
||||
: 'border-r-blue-500 bg-white dark:bg-gray-800'
|
||||
} border border-gray-200 dark:border-gray-600 rounded-lg shadow-sm hover:shadow-md transition-shadow`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{getNotificationIcon(notification)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3
|
||||
className={`text-sm font-medium ${
|
||||
notification.is_read
|
||||
? 'text-gray-600 dark:text-gray-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{notification.title}
|
||||
</h3>
|
||||
{!notification.is_read && (
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={`mt-1 text-sm whitespace-pre-line ${
|
||||
notification.is_read
|
||||
? 'text-gray-500 dark:text-gray-500'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{notification.message}
|
||||
</p>
|
||||
|
||||
{notification.metadata && Object.keys(notification.metadata).length > 0 && (
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-500">
|
||||
{notification.metadata.invoice_id && (
|
||||
<span className="ml-4">شماره فاکتور: {notification.metadata.invoice_id}</span>
|
||||
)}
|
||||
{notification.metadata.payment_id && (
|
||||
<span className="ml-4">شماره پرداخت: {notification.metadata.payment_id}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-500">
|
||||
{formatDateTime(notification.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 mr-4">
|
||||
{!notification.is_read && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleMarkAsRead(notification.id)}
|
||||
disabled={isMarkingRead}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6">
|
||||
<Pagination
|
||||
currentPage={Math.floor(filters.offset / filters.limit) + 1}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
itemsPerPage={filters.limit}
|
||||
totalItems={notifications.length}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminNotificationsListPage;
|
||||
|
|
@ -110,7 +110,6 @@ export interface Order {
|
|||
national_code?: string;
|
||||
verified: boolean;
|
||||
avatar?: string;
|
||||
is_deleted?: boolean;
|
||||
};
|
||||
payment_status?: PaymentStatus;
|
||||
payments?: OrderPaymentRecord[];
|
||||
|
|
|
|||
|
|
@ -179,13 +179,8 @@ const OrdersListPage = () => {
|
|||
align: 'right',
|
||||
render: (_val, row: any) => (
|
||||
<div className="text-right">
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
<div className="font-medium">
|
||||
{(row.user?.first_name || row.customer?.first_name || 'نامشخص')} {(row.user?.last_name || row.customer?.last_name || '')}
|
||||
{row.user?.is_deleted && (
|
||||
<span className="px-2 py-0.5 text-xs bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 rounded">
|
||||
حذف شده
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-500 dark:text-gray-400" dir="ltr" style={{ direction: 'ltr' }}>
|
||||
{row.user?.phone_number ? englishToPersian(row.user.phone_number) : '-'}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ export interface ProductFormData {
|
|||
explorer_file_ids?: ProductImage[];
|
||||
is_delete_latest_explorer_files?: boolean;
|
||||
product_cover_image_id?: string;
|
||||
sku?: string;
|
||||
}
|
||||
|
||||
export interface ProductVariantFormData {
|
||||
|
|
@ -87,8 +86,6 @@ export interface ProductVariantFormData {
|
|||
attributes: Record<string, any>;
|
||||
meta: Record<string, any>;
|
||||
file_ids: ProductImage[];
|
||||
gold_price_per_gram?: number;
|
||||
factory_fee_percentage?: number;
|
||||
}
|
||||
|
||||
export interface ProductFilters {
|
||||
|
|
@ -114,7 +111,6 @@ export interface CreateProductRequest {
|
|||
file_ids?: number[];
|
||||
explorer_file_ids?: number[];
|
||||
variants?: CreateVariantRequest[];
|
||||
sku?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProductRequest {
|
||||
|
|
@ -132,7 +128,6 @@ export interface UpdateProductRequest {
|
|||
explorer_file_ids?: number[];
|
||||
is_delete_latest_explorer_files?: boolean;
|
||||
variants?: UpdateVariantRequest[];
|
||||
sku?: string;
|
||||
}
|
||||
|
||||
export interface CreateVariantRequest {
|
||||
|
|
@ -148,8 +143,6 @@ export interface CreateVariantRequest {
|
|||
attributes?: Record<string, any>;
|
||||
meta?: Record<string, any>;
|
||||
file_ids?: number[];
|
||||
gold_price_per_gram?: number;
|
||||
factory_fee_percentage?: number;
|
||||
}
|
||||
|
||||
export interface UpdateVariantRequest {
|
||||
|
|
@ -165,8 +158,6 @@ export interface UpdateVariantRequest {
|
|||
attributes?: Record<string, any>;
|
||||
meta?: Record<string, any>;
|
||||
file_ids?: number[];
|
||||
gold_price_per_gram?: number;
|
||||
factory_fee_percentage?: number;
|
||||
}
|
||||
|
||||
export interface ProductsResponse {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ const productSchema = yup.object({
|
|||
explorer_file_ids: yup.array().of(yup.object()).default([]),
|
||||
is_delete_latest_explorer_files: yup.boolean().optional(),
|
||||
product_cover_image_id: yup.string().optional(),
|
||||
sku: yup.string().optional(),
|
||||
});
|
||||
|
||||
const toPublicUrl = (img: any): ProductImage => {
|
||||
|
|
@ -150,8 +149,7 @@ const ProductFormPage = () => {
|
|||
variants: [],
|
||||
explorer_file_ids: [],
|
||||
is_delete_latest_explorer_files: false,
|
||||
product_cover_image_id: undefined,
|
||||
sku: undefined
|
||||
product_cover_image_id: undefined
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -189,9 +187,7 @@ const ProductFormPage = () => {
|
|||
product_option_id: variant.product_option_id || undefined,
|
||||
attributes: variant.attributes || {},
|
||||
meta: variant.meta || {},
|
||||
file_ids: (variant.file_ids && variant.file_ids.length > 0 ? variant.file_ids : (variant as any).files || []),
|
||||
gold_price_per_gram: variant.gold_price_per_gram || undefined,
|
||||
factory_fee_percentage: variant.factory_fee_percentage || undefined
|
||||
file_ids: (variant.file_ids && variant.file_ids.length > 0 ? variant.file_ids : (variant as any).files || [])
|
||||
}));
|
||||
|
||||
console.log('✅ Successfully processed variants:', formVariants.length);
|
||||
|
|
@ -221,8 +217,7 @@ const ProductFormPage = () => {
|
|||
product_option_id: product.product_option_id || undefined,
|
||||
file_ids: (product.file_ids && product.file_ids.length > 0 ? product.file_ids : (product as any).files || []),
|
||||
variants: formVariants,
|
||||
product_cover_image_id: (product as any).product_cover_image_id ? (product as any).product_cover_image_id.toString() : undefined,
|
||||
sku: product.sku || undefined
|
||||
product_cover_image_id: (product as any).product_cover_image_id ? (product as any).product_cover_image_id.toString() : undefined
|
||||
});
|
||||
const initialImages = (product.file_ids && product.file_ids.length > 0 ? product.file_ids : (product as any).files || []);
|
||||
const normalizedImages: ProductImage[] = (initialImages || []).map(toPublicUrl);
|
||||
|
|
@ -340,7 +335,6 @@ const ProductFormPage = () => {
|
|||
category_ids: convertedData.category_ids.length > 0 ? convertedData.category_ids : [],
|
||||
product_option_id: convertedData.product_option_id || null,
|
||||
file_ids: validImageIds,
|
||||
sku: convertedData.sku || undefined,
|
||||
};
|
||||
|
||||
const submitBaseData = {
|
||||
|
|
@ -355,7 +349,7 @@ const ProductFormPage = () => {
|
|||
if (isEdit && id) {
|
||||
// برای update، variants باید شامل ID باشه
|
||||
const updateVariants = data.variants?.map((variant: any) => ({
|
||||
id: variant.id || 0,
|
||||
id: variant.id || 0, // اگر ID نداره، 0 بذار (برای variant جدید)
|
||||
enabled: variant.enabled,
|
||||
fee_percentage: variant.fee_percentage,
|
||||
profit_percentage: variant.profit_percentage,
|
||||
|
|
@ -366,9 +360,7 @@ const ProductFormPage = () => {
|
|||
weight: variant.weight,
|
||||
file_ids: Array.isArray(variant.file_ids) ? variant.file_ids.map((file: any) => Number(typeof file === 'object' ? file.id : file)).filter((id: number) => !isNaN(id)) : [],
|
||||
attributes: variant.attributes && convertedData.variant_attribute_name && variant.attributes[convertedData.variant_attribute_name] !== undefined ? { [convertedData.variant_attribute_name]: variant.attributes[convertedData.variant_attribute_name] } : {},
|
||||
meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {},
|
||||
gold_price_per_gram: variant.gold_price_per_gram || undefined,
|
||||
factory_fee_percentage: variant.factory_fee_percentage || undefined
|
||||
meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {}
|
||||
})) || [];
|
||||
|
||||
const updatePayload = {
|
||||
|
|
@ -398,9 +390,7 @@ const ProductFormPage = () => {
|
|||
weight: variant.weight,
|
||||
file_ids: Array.isArray(variant.file_ids) ? variant.file_ids.map((file: any) => Number(typeof file === 'object' ? file.id : file)).filter((id: number) => !isNaN(id)) : [],
|
||||
attributes: variant.attributes && convertedData.variant_attribute_name && variant.attributes[convertedData.variant_attribute_name] !== undefined ? { [convertedData.variant_attribute_name]: variant.attributes[convertedData.variant_attribute_name] } : {},
|
||||
meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {},
|
||||
gold_price_per_gram: variant.gold_price_per_gram || undefined,
|
||||
factory_fee_percentage: variant.factory_fee_percentage || undefined
|
||||
meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {}
|
||||
})) || [];
|
||||
|
||||
const createPayload: any = {
|
||||
|
|
@ -529,13 +519,6 @@ const ProductFormPage = () => {
|
|||
placeholder="مثال: آبکاری، رنگ، سایز..."
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="SKU"
|
||||
{...register('sku')}
|
||||
error={errors.sku?.message}
|
||||
placeholder="مثال: RING-001"
|
||||
/>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
توضیحات
|
||||
|
|
|
|||
|
|
@ -18,13 +18,12 @@ export const useDiscountUsageReport = (filters: DiscountUsageFilters) => {
|
|||
};
|
||||
|
||||
export const useCustomerDiscountUsageReport = (
|
||||
filters: CustomerDiscountUsageFilters & { _refetchKey?: number }
|
||||
filters: CustomerDiscountUsageFilters
|
||||
) => {
|
||||
const { _refetchKey, ...cleanFilters } = filters;
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_CUSTOMER_DISCOUNT_USAGE_REPORT, cleanFilters, _refetchKey],
|
||||
queryFn: () => getCustomerDiscountUsageReport(cleanFilters),
|
||||
enabled: cleanFilters.user_id > 0 && cleanFilters.limit > 0,
|
||||
queryKey: [QUERY_KEYS.GET_CUSTOMER_DISCOUNT_USAGE_REPORT, filters],
|
||||
queryFn: () => getCustomerDiscountUsageReport(filters),
|
||||
enabled: filters.user_id > 0 && filters.limit > 0,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService";
|
||||
import { httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService";
|
||||
import { API_ROUTES } from "@/constant/routes";
|
||||
import {
|
||||
DiscountUsageFilters,
|
||||
|
|
@ -10,19 +10,9 @@ import {
|
|||
export const getDiscountUsageReport = async (
|
||||
filters: DiscountUsageFilters
|
||||
): Promise<DiscountUsageResponse> => {
|
||||
const queryParams: Record<string, string | number | boolean> = {};
|
||||
|
||||
if (filters.date_range?.from) queryParams.from = filters.date_range.from;
|
||||
if (filters.date_range?.to) queryParams.to = filters.date_range.to;
|
||||
if (filters.discount_code) queryParams.discount_code = filters.discount_code;
|
||||
if (filters.discount_id !== undefined) queryParams.discount_id = filters.discount_id;
|
||||
if (filters.user_id !== undefined) queryParams.user_id = filters.user_id;
|
||||
if (filters.group_by_code !== undefined) queryParams.group_by_code = filters.group_by_code;
|
||||
if (filters.limit !== undefined) queryParams.limit = filters.limit;
|
||||
if (filters.offset !== undefined) queryParams.offset = filters.offset;
|
||||
|
||||
const response = await httpGetRequest<DiscountUsageResponse>(
|
||||
APIUrlGenerator(API_ROUTES.DISCOUNT_USAGE_REPORT, queryParams)
|
||||
const response = await httpPostRequest<DiscountUsageResponse>(
|
||||
APIUrlGenerator(API_ROUTES.DISCOUNT_USAGE_REPORT),
|
||||
filters
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
|
@ -30,18 +20,9 @@ export const getDiscountUsageReport = async (
|
|||
export const getCustomerDiscountUsageReport = async (
|
||||
filters: CustomerDiscountUsageFilters
|
||||
): Promise<CustomerDiscountUsageResponse> => {
|
||||
const queryParams: Record<string, string | number> = {};
|
||||
|
||||
queryParams.user_id = filters.user_id;
|
||||
if (filters.date_range?.from) queryParams.from = filters.date_range.from;
|
||||
if (filters.date_range?.to) queryParams.to = filters.date_range.to;
|
||||
if (filters.discount_code) queryParams.discount_code = filters.discount_code;
|
||||
if (filters.discount_id !== undefined) queryParams.discount_id = filters.discount_id;
|
||||
if (filters.limit !== undefined) queryParams.limit = filters.limit;
|
||||
if (filters.offset !== undefined) queryParams.offset = filters.offset;
|
||||
|
||||
const response = await httpGetRequest<CustomerDiscountUsageResponse>(
|
||||
APIUrlGenerator(API_ROUTES.CUSTOMER_DISCOUNT_USAGE_REPORT, queryParams)
|
||||
const response = await httpPostRequest<CustomerDiscountUsageResponse>(
|
||||
APIUrlGenerator(API_ROUTES.CUSTOMER_DISCOUNT_USAGE_REPORT),
|
||||
filters
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useCustomerDiscountUsageReport } from '../core/_hooks';
|
||||
import { CustomerDiscountUsageFilters } from '../core/_models';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
|
@ -22,35 +22,24 @@ const CustomerDiscountUsagePage = () => {
|
|||
offset: 0,
|
||||
});
|
||||
|
||||
const [tempFilters, setTempFilters] = useState<CustomerDiscountUsageFilters>({
|
||||
user_id: 0,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
const { data, isLoading, error } = useCustomerDiscountUsageReport(filters);
|
||||
|
||||
const [refetchKey, setRefetchKey] = useState(0);
|
||||
|
||||
const filtersWithKey = useMemo(() => ({
|
||||
...filters,
|
||||
_refetchKey: refetchKey,
|
||||
}), [filters, refetchKey]);
|
||||
|
||||
const { data, isLoading, error } = useCustomerDiscountUsageReport(filtersWithKey as CustomerDiscountUsageFilters & { _refetchKey?: number });
|
||||
|
||||
const handleTempFilterChange = (key: keyof CustomerDiscountUsageFilters, value: any) => {
|
||||
setTempFilters(prev => ({
|
||||
const handleFilterChange = (key: keyof CustomerDiscountUsageFilters, value: any) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
offset: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDateRangeChange = (from: string | undefined, to: string | undefined) => {
|
||||
setTempFilters(prev => ({
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
date_range: {
|
||||
from,
|
||||
to,
|
||||
},
|
||||
offset: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
|
|
@ -58,21 +47,12 @@ const CustomerDiscountUsagePage = () => {
|
|||
const converted = persianToEnglish(raw).replace(/[^\d]/g, '');
|
||||
const numeric = converted ? Number(converted) : undefined;
|
||||
if (key === 'user_id') {
|
||||
handleTempFilterChange('user_id', numeric || 0);
|
||||
handleFilterChange('user_id', numeric || 0);
|
||||
} else {
|
||||
handleTempFilterChange(key, numeric);
|
||||
handleFilterChange(key, numeric);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
const newFilters = {
|
||||
...tempFilters,
|
||||
offset: 0,
|
||||
};
|
||||
setFilters(newFilters);
|
||||
setRefetchKey(prev => prev + 1);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
|
|
@ -81,13 +61,11 @@ const CustomerDiscountUsagePage = () => {
|
|||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
const clearedFilters = {
|
||||
setFilters({
|
||||
user_id: 0,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
};
|
||||
setTempFilters(clearedFilters);
|
||||
setFilters(clearedFilters);
|
||||
});
|
||||
};
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
|
|
@ -140,15 +118,6 @@ const CustomerDiscountUsagePage = () => {
|
|||
<Filter className="h-5 w-5 text-gray-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleApplyFilters}
|
||||
disabled={!tempFilters.user_id || tempFilters.user_id === 0}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
اعمال فیلترها
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
|
@ -159,13 +128,6 @@ const CustomerDiscountUsagePage = () => {
|
|||
پاک کردن فیلترها
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<span className="font-semibold">توجه:</span> برای مشاهده گزارش، لطفاً شناسه کاربر را وارد کنید. این فیلد الزامی است.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
|
|
@ -173,7 +135,7 @@ const CustomerDiscountUsagePage = () => {
|
|||
شناسه کاربر <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.user_id?.toString() || ''}
|
||||
value={filters.user_id?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('user_id', e.target.value)}
|
||||
placeholder="مثلاً 456"
|
||||
numeric
|
||||
|
|
@ -186,8 +148,8 @@ const CustomerDiscountUsagePage = () => {
|
|||
کد تخفیف
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.discount_code || ''}
|
||||
onChange={(e) => handleTempFilterChange('discount_code', e.target.value || undefined)}
|
||||
value={filters.discount_code || ''}
|
||||
onChange={(e) => handleFilterChange('discount_code', e.target.value || undefined)}
|
||||
placeholder="مثلاً SUMMER2025"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -197,7 +159,7 @@ const CustomerDiscountUsagePage = () => {
|
|||
شناسه کد تخفیف
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.discount_id?.toString() || ''}
|
||||
value={filters.discount_id?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('discount_id', e.target.value)}
|
||||
placeholder="مثلاً 123"
|
||||
numeric
|
||||
|
|
@ -209,8 +171,8 @@ const CustomerDiscountUsagePage = () => {
|
|||
تاریخ شروع
|
||||
</label>
|
||||
<JalaliDateTimePicker
|
||||
value={tempFilters.date_range?.from}
|
||||
onChange={(value) => handleDateRangeChange(value, tempFilters.date_range?.to)}
|
||||
value={filters.date_range?.from}
|
||||
onChange={(value) => handleDateRangeChange(value, filters.date_range?.to)}
|
||||
placeholder="انتخاب تاریخ شروع"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -220,8 +182,8 @@ const CustomerDiscountUsagePage = () => {
|
|||
تاریخ پایان
|
||||
</label>
|
||||
<JalaliDateTimePicker
|
||||
value={tempFilters.date_range?.to}
|
||||
onChange={(value) => handleDateRangeChange(tempFilters.date_range?.from, value)}
|
||||
value={filters.date_range?.to}
|
||||
onChange={(value) => handleDateRangeChange(filters.date_range?.from, value)}
|
||||
placeholder="انتخاب تاریخ پایان"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { QUERY_KEYS } from "@/utils/query-key";
|
||||
import { getInventoryValueReport } from "./_requests";
|
||||
|
||||
export const useInventoryValueReport = () => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_INVENTORY_VALUE_REPORT],
|
||||
queryFn: () => getInventoryValueReport(),
|
||||
});
|
||||
};
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
export interface FactoryFeeBreakdown {
|
||||
factory_fee_percentage: number;
|
||||
raw_weight: number;
|
||||
weight_with_fee: number;
|
||||
amount: number;
|
||||
variants_count: number;
|
||||
stock_quantity: number;
|
||||
}
|
||||
|
||||
export interface InventoryValueResponse {
|
||||
raw_inventory_amount: number;
|
||||
raw_inventory_weight: number;
|
||||
factory_fee_inventory_amount: number;
|
||||
factory_fee_inventory_weight: number;
|
||||
factory_fee_breakdown: FactoryFeeBreakdown[];
|
||||
current_gold_price: number;
|
||||
total_variants_count: number;
|
||||
total_stock_quantity: number;
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService";
|
||||
import { API_ROUTES } from "@/constant/routes";
|
||||
import { InventoryValueResponse } from "./_models";
|
||||
|
||||
export const getInventoryValueReport = async (): Promise<InventoryValueResponse> => {
|
||||
const response = await httpGetRequest<InventoryValueResponse>(
|
||||
APIUrlGenerator(API_ROUTES.INVENTORY_VALUE_REPORT)
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { useInventoryValueReport } from '../core/_hooks';
|
||||
import { Table } from '@/components/ui/Table';
|
||||
import { TableColumn } from '@/types';
|
||||
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
||||
import { Pagination } from '@/components/ui/Pagination';
|
||||
import { DollarSign, Package, TrendingUp, Hash } from 'lucide-react';
|
||||
import { formatWithThousands } from '@/utils/numberUtils';
|
||||
import { formatCurrency } from '@/utils/formatters';
|
||||
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
|
||||
|
||||
const InventoryValueReportPage = () => {
|
||||
const { data, isLoading, error } = useInventoryValueReport();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
const columns: TableColumn[] = useMemo(() => [
|
||||
{
|
||||
key: 'factory_fee_percentage',
|
||||
label: 'درصد اجرت کارخانه',
|
||||
align: 'right',
|
||||
render: (val: number) => `${formatWithThousands(val, 1)}%`,
|
||||
},
|
||||
{
|
||||
key: 'raw_weight',
|
||||
label: 'وزن خام (گرم)',
|
||||
align: 'right',
|
||||
render: (val: number) => formatWithThousands(val, 2) + ' گرم',
|
||||
},
|
||||
{
|
||||
key: 'weight_with_fee',
|
||||
label: 'وزن با اجرت (گرم)',
|
||||
align: 'right',
|
||||
render: (val: number) => formatWithThousands(val, 2) + ' گرم',
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: 'ارزش (تومان)',
|
||||
align: 'right',
|
||||
render: (val: number) => formatCurrency(val),
|
||||
},
|
||||
{
|
||||
key: 'variants_count',
|
||||
label: 'تعداد Variant',
|
||||
align: 'right',
|
||||
render: (val: number) => formatWithThousands(val),
|
||||
},
|
||||
{
|
||||
key: 'stock_quantity',
|
||||
label: 'موجودی',
|
||||
align: 'right',
|
||||
render: (val: number) => formatWithThousands(val),
|
||||
},
|
||||
], []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<ReportSkeleton />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">خطا در بارگذاری گزارش</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageTitle>گزارش ارزش موجودی</PageTitle>
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<DollarSign className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">ارزش موجودی خام</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatCurrency(data.raw_inventory_amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
|
||||
<DollarSign className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">ارزش با اجرت کارخانه</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatCurrency(data.factory_fee_inventory_amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
|
||||
<Package className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">وزن خام</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(data.raw_inventory_weight, 2)} گرم
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
|
||||
<TrendingUp className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">قیمت لحظهای طلا</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatCurrency(data.current_gold_price)}/گرم
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-indigo-100 dark:bg-indigo-900 rounded-lg">
|
||||
<Hash className="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">تعداد کل Variant</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(data.total_variants_count)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-teal-100 dark:bg-teal-900 rounded-lg">
|
||||
<Package className="h-5 w-5 text-teal-600 dark:text-teal-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">موجودی کل</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(data.total_stock_quantity)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
تفکیک بر اساس درصد اجرت کارخانه
|
||||
</h3>
|
||||
{(() => {
|
||||
const breakdownData = data.factory_fee_breakdown || [];
|
||||
const totalPages = Math.ceil(breakdownData.length / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedData = breakdownData.slice(startIndex, endIndex);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table columns={columns} data={paginatedData} />
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
itemsPerPage={itemsPerPage}
|
||||
totalItems={breakdownData.length}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default InventoryValueReportPage;
|
||||
|
|
@ -36,42 +36,31 @@ const PaymentMethodsReportPage = () => {
|
|||
group_by_user: false,
|
||||
});
|
||||
|
||||
const [tempFilters, setTempFilters] = useState<PaymentMethodsFilters>({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
group_by_user: false,
|
||||
});
|
||||
|
||||
const { data, isLoading, error } = usePaymentMethodsReport(filters);
|
||||
|
||||
const handleTempFilterChange = (key: keyof PaymentMethodsFilters, value: any) => {
|
||||
setTempFilters(prev => ({
|
||||
const handleFilterChange = (key: keyof PaymentMethodsFilters, value: any) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
offset: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDateRangeChange = (from: string | undefined, to: string | undefined) => {
|
||||
setTempFilters(prev => ({
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
date_range: {
|
||||
from,
|
||||
to,
|
||||
},
|
||||
offset: 0,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleNumericFilterChange = (key: 'user_id', raw: string) => {
|
||||
const converted = persianToEnglish(raw).replace(/[^\d]/g, '');
|
||||
const numeric = converted ? Number(converted) : undefined;
|
||||
handleTempFilterChange(key, numeric);
|
||||
};
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
setFilters({
|
||||
...tempFilters,
|
||||
offset: 0,
|
||||
});
|
||||
handleFilterChange(key, numeric);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
|
|
@ -82,13 +71,11 @@ const PaymentMethodsReportPage = () => {
|
|||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
const clearedFilters = {
|
||||
setFilters({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
group_by_user: false,
|
||||
};
|
||||
setTempFilters(clearedFilters);
|
||||
setFilters(clearedFilters);
|
||||
});
|
||||
};
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
|
|
@ -171,14 +158,6 @@ const PaymentMethodsReportPage = () => {
|
|||
<Filter className="h-5 w-5 text-gray-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleApplyFilters}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
اعمال فیلترها
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
|
@ -189,7 +168,6 @@ const PaymentMethodsReportPage = () => {
|
|||
پاک کردن فیلترها
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
|
|
@ -197,7 +175,7 @@ const PaymentMethodsReportPage = () => {
|
|||
شناسه کاربر
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.user_id?.toString() || ''}
|
||||
value={filters.user_id?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('user_id', e.target.value)}
|
||||
placeholder="مثلاً 456"
|
||||
numeric
|
||||
|
|
@ -209,8 +187,8 @@ const PaymentMethodsReportPage = () => {
|
|||
نوع پرداخت
|
||||
</label>
|
||||
<select
|
||||
value={tempFilters.payment_type || ''}
|
||||
onChange={(e) => handleTempFilterChange('payment_type', e.target.value || undefined)}
|
||||
value={filters.payment_type || ''}
|
||||
onChange={(e) => handleFilterChange('payment_type', e.target.value || undefined)}
|
||||
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||
>
|
||||
<option value="">همه</option>
|
||||
|
|
@ -226,8 +204,8 @@ const PaymentMethodsReportPage = () => {
|
|||
وضعیت
|
||||
</label>
|
||||
<select
|
||||
value={tempFilters.status || ''}
|
||||
onChange={(e) => handleTempFilterChange('status', e.target.value || undefined)}
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value || undefined)}
|
||||
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||
>
|
||||
<option value="">همه</option>
|
||||
|
|
@ -244,8 +222,8 @@ const PaymentMethodsReportPage = () => {
|
|||
تاریخ شروع
|
||||
</label>
|
||||
<JalaliDateTimePicker
|
||||
value={tempFilters.date_range?.from}
|
||||
onChange={(value) => handleDateRangeChange(value, tempFilters.date_range?.to)}
|
||||
value={filters.date_range?.from}
|
||||
onChange={(value) => handleDateRangeChange(value, filters.date_range?.to)}
|
||||
placeholder="انتخاب تاریخ شروع"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -255,8 +233,8 @@ const PaymentMethodsReportPage = () => {
|
|||
تاریخ پایان
|
||||
</label>
|
||||
<JalaliDateTimePicker
|
||||
value={tempFilters.date_range?.to}
|
||||
onChange={(value) => handleDateRangeChange(tempFilters.date_range?.from, value)}
|
||||
value={filters.date_range?.to}
|
||||
onChange={(value) => handleDateRangeChange(filters.date_range?.from, value)}
|
||||
placeholder="انتخاب تاریخ پایان"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -265,8 +243,8 @@ const PaymentMethodsReportPage = () => {
|
|||
<label className="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tempFilters.group_by_user || false}
|
||||
onChange={(e) => handleTempFilterChange('group_by_user', e.target.checked)}
|
||||
checked={filters.group_by_user || false}
|
||||
onChange={(e) => handleFilterChange('group_by_user', e.target.checked)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
گروهبندی بر اساس کاربر
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { QUERY_KEYS } from "@/utils/query-key";
|
||||
import { getProfitLossReport } from "./_requests";
|
||||
import { ProfitLossFilters } from "./_models";
|
||||
|
||||
export const useProfitLossReport = (filters?: ProfitLossFilters) => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_PROFIT_LOSS_REPORT, filters],
|
||||
queryFn: () => getProfitLossReport(filters),
|
||||
});
|
||||
};
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
export interface ProfitLossFilters {
|
||||
from?: string;
|
||||
to?: string;
|
||||
product_sku?: string;
|
||||
category_name?: string;
|
||||
min_profit_loss_grams?: number;
|
||||
max_profit_loss_grams?: number;
|
||||
min_profit_loss_tomans?: number;
|
||||
max_profit_loss_tomans?: number;
|
||||
}
|
||||
|
||||
export interface ProfitLossPeriod {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
}
|
||||
|
||||
export interface ProductBreakdown {
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
product_sku: string;
|
||||
profit_loss_grams: number;
|
||||
profit_loss_tomans: number;
|
||||
total_quantity: number;
|
||||
order_items_count: number;
|
||||
}
|
||||
|
||||
export interface CategoryBreakdown {
|
||||
category_id: number;
|
||||
category_name: string;
|
||||
profit_loss_grams: number;
|
||||
profit_loss_tomans: number;
|
||||
total_quantity: number;
|
||||
products_count: number;
|
||||
order_items_count: number;
|
||||
}
|
||||
|
||||
export interface ProfitLossResponse {
|
||||
profit_loss: {
|
||||
profit_loss_grams: number;
|
||||
profit_loss_tomans: number;
|
||||
period: ProfitLossPeriod;
|
||||
product_breakdown: ProductBreakdown[];
|
||||
category_breakdown: CategoryBreakdown[];
|
||||
};
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService";
|
||||
import { API_ROUTES } from "@/constant/routes";
|
||||
import { ProfitLossFilters, ProfitLossResponse } from "./_models";
|
||||
|
||||
export const getProfitLossReport = async (
|
||||
filters?: ProfitLossFilters
|
||||
): Promise<ProfitLossResponse> => {
|
||||
const queryParams: Record<string, string | number> = {};
|
||||
|
||||
if (filters?.from) queryParams.from = filters.from;
|
||||
if (filters?.to) queryParams.to = filters.to;
|
||||
if (filters?.product_sku) queryParams.product_sku = filters.product_sku;
|
||||
if (filters?.category_name) queryParams.category_name = filters.category_name;
|
||||
if (filters?.min_profit_loss_grams !== undefined) queryParams.min_profit_loss_grams = filters.min_profit_loss_grams;
|
||||
if (filters?.max_profit_loss_grams !== undefined) queryParams.max_profit_loss_grams = filters.max_profit_loss_grams;
|
||||
if (filters?.min_profit_loss_tomans !== undefined) queryParams.min_profit_loss_tomans = filters.min_profit_loss_tomans;
|
||||
if (filters?.max_profit_loss_tomans !== undefined) queryParams.max_profit_loss_tomans = filters.max_profit_loss_tomans;
|
||||
|
||||
const response = await httpGetRequest<ProfitLossResponse>(
|
||||
APIUrlGenerator(API_ROUTES.PROFIT_LOSS_REPORT, queryParams)
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
|
@ -1,432 +0,0 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { useProfitLossReport } from '../core/_hooks';
|
||||
import { ProfitLossFilters } from '../core/_models';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Table } from '@/components/ui/Table';
|
||||
import { TableColumn } from '@/types';
|
||||
import { JalaliDateTimePicker } from '@/components/ui/JalaliDateTimePicker';
|
||||
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
||||
import { Pagination } from '@/components/ui/Pagination';
|
||||
import { Filter, TrendingUp, TrendingDown, DollarSign, Package, X } from 'lucide-react';
|
||||
import { formatWithThousands, parseFormattedNumber, persianToEnglish } from '@/utils/numberUtils';
|
||||
import { formatCurrency } from '@/utils/formatters';
|
||||
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
|
||||
|
||||
const ProfitLossReportPage = () => {
|
||||
const [filters, setFilters] = useState<ProfitLossFilters>({});
|
||||
const [tempFilters, setTempFilters] = useState<ProfitLossFilters>({});
|
||||
const [productPage, setProductPage] = useState(1);
|
||||
const [categoryPage, setCategoryPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
|
||||
const { data, isLoading, error } = useProfitLossReport(filters);
|
||||
|
||||
const handleTempFilterChange = (key: keyof ProfitLossFilters, value: any) => {
|
||||
setTempFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDateChange = (key: 'from' | 'to', value: string | undefined) => {
|
||||
handleTempFilterChange(key, value);
|
||||
};
|
||||
|
||||
const handleNumericFilterChange = (
|
||||
key: 'min_profit_loss_grams' | 'max_profit_loss_grams' | 'min_profit_loss_tomans' | 'max_profit_loss_tomans',
|
||||
raw: string
|
||||
) => {
|
||||
const converted = persianToEnglish(raw);
|
||||
const numeric = parseFormattedNumber(converted);
|
||||
handleTempFilterChange(key, numeric || undefined);
|
||||
};
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
setFilters(tempFilters);
|
||||
setProductPage(1);
|
||||
setCategoryPage(1);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
const clearedFilters = {};
|
||||
setTempFilters(clearedFilters);
|
||||
setFilters(clearedFilters);
|
||||
setProductPage(1);
|
||||
setCategoryPage(1);
|
||||
};
|
||||
|
||||
const productColumns: TableColumn[] = useMemo(() => [
|
||||
{
|
||||
key: 'product_name',
|
||||
label: 'نام محصول',
|
||||
align: 'right',
|
||||
render: (_val, row: any) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.product_name}</div>
|
||||
{row.product_sku && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">SKU: {row.product_sku}</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'profit_loss_grams',
|
||||
label: 'سود/زیان (گرم)',
|
||||
align: 'right',
|
||||
render: (val: number) => (
|
||||
<span className={val >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
|
||||
{val >= 0 ? '+' : ''}{formatWithThousands(val, 2)} گرم
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'profit_loss_tomans',
|
||||
label: 'سود/زیان (تومان)',
|
||||
align: 'right',
|
||||
render: (val: number) => (
|
||||
<span className={val >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
|
||||
{val >= 0 ? '+' : ''}{formatCurrency(val)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'total_quantity',
|
||||
label: 'تعداد فروش',
|
||||
align: 'right',
|
||||
render: (val: number) => formatWithThousands(val),
|
||||
},
|
||||
{
|
||||
key: 'order_items_count',
|
||||
label: 'تعداد آیتمها',
|
||||
align: 'right',
|
||||
render: (val: number) => formatWithThousands(val),
|
||||
},
|
||||
], []);
|
||||
|
||||
const categoryColumns: TableColumn[] = useMemo(() => [
|
||||
{
|
||||
key: 'category_name',
|
||||
label: 'نام دستهبندی',
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
key: 'profit_loss_grams',
|
||||
label: 'سود/زیان (گرم)',
|
||||
align: 'right',
|
||||
render: (val: number) => (
|
||||
<span className={val >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
|
||||
{val >= 0 ? '+' : ''}{formatWithThousands(val, 2)} گرم
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'profit_loss_tomans',
|
||||
label: 'سود/زیان (تومان)',
|
||||
align: 'right',
|
||||
render: (val: number) => (
|
||||
<span className={val >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
|
||||
{val >= 0 ? '+' : ''}{formatCurrency(val)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'total_quantity',
|
||||
label: 'تعداد فروش',
|
||||
align: 'right',
|
||||
render: (val: number) => formatWithThousands(val),
|
||||
},
|
||||
{
|
||||
key: 'products_count',
|
||||
label: 'تعداد محصولات',
|
||||
align: 'right',
|
||||
render: (val: number) => formatWithThousands(val),
|
||||
},
|
||||
{
|
||||
key: 'order_items_count',
|
||||
label: 'تعداد آیتمها',
|
||||
align: 'right',
|
||||
render: (val: number) => formatWithThousands(val),
|
||||
},
|
||||
], []);
|
||||
|
||||
const profitLossData = data?.profit_loss;
|
||||
const isProfit = profitLossData ? profitLossData.profit_loss_grams >= 0 : false;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<ReportSkeleton />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">خطا در بارگذاری گزارش</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageTitle>گزارش سود و زیان</PageTitle>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5 text-gray-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleApplyFilters}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
اعمال فیلترها
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleClearFilters}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
پاک کردن فیلترها
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
تاریخ شروع
|
||||
</label>
|
||||
<JalaliDateTimePicker
|
||||
value={tempFilters.from}
|
||||
onChange={(value) => handleDateChange('from', value)}
|
||||
placeholder="انتخاب تاریخ شروع"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
تاریخ پایان
|
||||
</label>
|
||||
<JalaliDateTimePicker
|
||||
value={tempFilters.to}
|
||||
onChange={(value) => handleDateChange('to', value)}
|
||||
placeholder="انتخاب تاریخ پایان"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
SKU محصول
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.product_sku || ''}
|
||||
onChange={(e) => handleTempFilterChange('product_sku', e.target.value || undefined)}
|
||||
placeholder="مثلاً RING-001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
نام دستهبندی
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.category_name || ''}
|
||||
onChange={(e) => handleTempFilterChange('category_name', e.target.value || undefined)}
|
||||
placeholder="مثلاً ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
حداقل سود/زیان (گرم)
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.min_profit_loss_grams?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('min_profit_loss_grams', e.target.value)}
|
||||
placeholder="مثلاً ۱۰.۵"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
حداکثر سود/زیان (گرم)
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.max_profit_loss_grams?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('max_profit_loss_grams', e.target.value)}
|
||||
placeholder="مثلاً ۱۰۰"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
حداقل سود/زیان (تومان)
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.min_profit_loss_tomans?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('min_profit_loss_tomans', e.target.value)}
|
||||
placeholder="مثلاً ۱۰۰۰۰۰۰"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
حداکثر سود/زیان (تومان)
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.max_profit_loss_tomans?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('max_profit_loss_tomans', e.target.value)}
|
||||
placeholder="مثلاً ۵۰۰۰۰۰۰۰"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profitLossData && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div className={`bg-white dark:bg-gray-800 shadow-sm border-2 rounded-lg p-6 ${
|
||||
isProfit
|
||||
? 'border-green-500 dark:border-green-400'
|
||||
: 'border-red-500 dark:border-red-400'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-3 rounded-lg ${
|
||||
isProfit
|
||||
? 'bg-green-100 dark:bg-green-900'
|
||||
: 'bg-red-100 dark:bg-red-900'
|
||||
}`}>
|
||||
{isProfit ? (
|
||||
<TrendingUp className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<TrendingDown className="h-6 w-6 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">سود/زیان کل (گرم)</p>
|
||||
<p className={`text-2xl font-bold ${
|
||||
isProfit
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{profitLossData.profit_loss_grams >= 0 ? '+' : ''}
|
||||
{formatWithThousands(profitLossData.profit_loss_grams, 2)} گرم
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`bg-white dark:bg-gray-800 shadow-sm border-2 rounded-lg p-6 ${
|
||||
isProfit
|
||||
? 'border-green-500 dark:border-green-400'
|
||||
: 'border-red-500 dark:border-red-400'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-3 rounded-lg ${
|
||||
isProfit
|
||||
? 'bg-green-100 dark:bg-green-900'
|
||||
: 'bg-red-100 dark:bg-red-900'
|
||||
}`}>
|
||||
{isProfit ? (
|
||||
<DollarSign className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<DollarSign className="h-6 w-6 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">سود/زیان کل (تومان)</p>
|
||||
<p className={`text-2xl font-bold ${
|
||||
isProfit
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{profitLossData.profit_loss_tomans >= 0 ? '+' : ''}
|
||||
{formatCurrency(profitLossData.profit_loss_tomans)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
تفکیک محصولات
|
||||
</h3>
|
||||
{(() => {
|
||||
const productData = profitLossData.product_breakdown || [];
|
||||
const productTotalPages = Math.ceil(productData.length / itemsPerPage);
|
||||
const productStartIndex = (productPage - 1) * itemsPerPage;
|
||||
const productEndIndex = productStartIndex + itemsPerPage;
|
||||
const paginatedProductData = productData.slice(productStartIndex, productEndIndex);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table columns={productColumns} data={paginatedProductData} />
|
||||
{productTotalPages > 1 && (
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
currentPage={productPage}
|
||||
totalPages={productTotalPages}
|
||||
onPageChange={setProductPage}
|
||||
itemsPerPage={itemsPerPage}
|
||||
totalItems={productData.length}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
تفکیک دستهبندیها
|
||||
</h3>
|
||||
{(() => {
|
||||
const categoryData = profitLossData.category_breakdown || [];
|
||||
const categoryTotalPages = Math.ceil(categoryData.length / itemsPerPage);
|
||||
const categoryStartIndex = (categoryPage - 1) * itemsPerPage;
|
||||
const categoryEndIndex = categoryStartIndex + itemsPerPage;
|
||||
const paginatedCategoryData = categoryData.slice(categoryStartIndex, categoryEndIndex);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table columns={categoryColumns} data={paginatedCategoryData} />
|
||||
{categoryTotalPages > 1 && (
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
currentPage={categoryPage}
|
||||
totalPages={categoryTotalPages}
|
||||
onPageChange={setCategoryPage}
|
||||
itemsPerPage={itemsPerPage}
|
||||
totalItems={categoryData.length}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfitLossReportPage;
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { QUERY_KEYS } from "@/utils/query-key";
|
||||
import { getSalesSummaryReport } from "./_requests";
|
||||
import { SalesSummaryFilters } from "./_models";
|
||||
|
||||
export const useSalesSummaryReport = (filters: SalesSummaryFilters) => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_SALES_SUMMARY_REPORT, filters],
|
||||
queryFn: () => getSalesSummaryReport(filters),
|
||||
});
|
||||
};
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
export interface SalesSummaryFilters {
|
||||
from: string;
|
||||
to: string;
|
||||
status?: string[];
|
||||
product_sku?: string;
|
||||
product_name?: string;
|
||||
min_quantity?: number;
|
||||
max_quantity?: number;
|
||||
min_weight?: number;
|
||||
max_weight?: number;
|
||||
min_sales?: number;
|
||||
max_sales?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface SalesSummaryPeriod {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface SalesSummaryFiltersApplied {
|
||||
status: string[];
|
||||
}
|
||||
|
||||
export interface ProductBreakdown {
|
||||
product_id: number;
|
||||
product_sku: string;
|
||||
product_name: string;
|
||||
total_weight: number;
|
||||
total_final_weight: number;
|
||||
total_quantity: number;
|
||||
total_sales_amount: number;
|
||||
average_price: number;
|
||||
average_weight: number;
|
||||
variant_count: number;
|
||||
image_url?: string;
|
||||
thumbnail_url?: string;
|
||||
}
|
||||
|
||||
export interface ProductsPagination {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface SalesSummaryResponse {
|
||||
total_sales_amount: number;
|
||||
total_gold_weight: number;
|
||||
total_final_weight: number;
|
||||
total_orders: number;
|
||||
average_order_value: number;
|
||||
average_price: number;
|
||||
average_weight: number;
|
||||
total_discount: number;
|
||||
products_breakdown: ProductBreakdown[];
|
||||
products_pagination: ProductsPagination;
|
||||
period: SalesSummaryPeriod;
|
||||
filters: SalesSummaryFiltersApplied;
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService";
|
||||
import { API_ROUTES } from "@/constant/routes";
|
||||
import { SalesSummaryFilters, SalesSummaryResponse } from "./_models";
|
||||
|
||||
export const getSalesSummaryReport = async (
|
||||
filters: SalesSummaryFilters
|
||||
): Promise<SalesSummaryResponse> => {
|
||||
const queryParams: Record<string, string | number | string[]> = {};
|
||||
|
||||
queryParams.from = filters.from;
|
||||
queryParams.to = filters.to;
|
||||
|
||||
if (filters.status) queryParams.status = filters.status;
|
||||
if (filters.product_sku) queryParams.product_sku = filters.product_sku;
|
||||
if (filters.product_name) queryParams.product_name = filters.product_name;
|
||||
if (filters.min_quantity !== undefined) queryParams.min_quantity = filters.min_quantity;
|
||||
if (filters.max_quantity !== undefined) queryParams.max_quantity = filters.max_quantity;
|
||||
if (filters.min_weight !== undefined) queryParams.min_weight = filters.min_weight;
|
||||
if (filters.max_weight !== undefined) queryParams.max_weight = filters.max_weight;
|
||||
if (filters.min_sales !== undefined) queryParams.min_sales = filters.min_sales;
|
||||
if (filters.max_sales !== undefined) queryParams.max_sales = filters.max_sales;
|
||||
if (filters.limit !== undefined) queryParams.limit = filters.limit;
|
||||
if (filters.offset !== undefined) queryParams.offset = filters.offset;
|
||||
|
||||
const response = await httpGetRequest<SalesSummaryResponse>(
|
||||
APIUrlGenerator(API_ROUTES.SALES_SUMMARY_REPORT, queryParams)
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
|
@ -1,440 +0,0 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { useSalesSummaryReport } from '../core/_hooks';
|
||||
import { SalesSummaryFilters } from '../core/_models';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Table } from '@/components/ui/Table';
|
||||
import { TableColumn } from '@/types';
|
||||
import { JalaliDateTimePicker } from '@/components/ui/JalaliDateTimePicker';
|
||||
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
||||
import { Pagination } from '@/components/ui/Pagination';
|
||||
import { Filter, TrendingUp, DollarSign, Package, ShoppingCart, X, Image as ImageIcon } from 'lucide-react';
|
||||
import { formatWithThousands, parseFormattedNumber, persianToEnglish } from '@/utils/numberUtils';
|
||||
import { formatCurrency } from '@/utils/formatters';
|
||||
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
|
||||
import DateObject from 'react-date-object';
|
||||
|
||||
const toIsoString = (date: DateObject): string => {
|
||||
try {
|
||||
const g = date.convert(undefined);
|
||||
const yyyy = g.year.toString().padStart(4, '0');
|
||||
const mm = g.month.toString().padStart(2, '0');
|
||||
const dd = g.day.toString().padStart(2, '0');
|
||||
const hh = (g.hour || 0).toString().padStart(2, '0');
|
||||
const mi = (g.minute || 0).toString().padStart(2, '0');
|
||||
const ss = (g.second || 0).toString().padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}Z`;
|
||||
} catch {
|
||||
const now = new Date();
|
||||
return now.toISOString();
|
||||
}
|
||||
};
|
||||
|
||||
const getDefaultDateRange = () => {
|
||||
const now = new DateObject();
|
||||
const thirtyDaysAgo = new DateObject().subtract(30, 'days');
|
||||
return {
|
||||
from: toIsoString(thirtyDaysAgo),
|
||||
to: toIsoString(now),
|
||||
};
|
||||
};
|
||||
|
||||
const SalesSummaryReportPage = () => {
|
||||
const defaultDates = getDefaultDateRange();
|
||||
|
||||
const [filters, setFilters] = useState<SalesSummaryFilters>({
|
||||
from: defaultDates.from,
|
||||
to: defaultDates.to,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const [tempFilters, setTempFilters] = useState<SalesSummaryFilters>({
|
||||
from: defaultDates.from,
|
||||
to: defaultDates.to,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const { data, isLoading, error } = useSalesSummaryReport(filters);
|
||||
|
||||
const handleTempFilterChange = (key: keyof SalesSummaryFilters, value: any) => {
|
||||
setTempFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDateChange = (key: 'from' | 'to', value: string | undefined) => {
|
||||
if (value) {
|
||||
handleTempFilterChange(key, value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNumericFilterChange = (
|
||||
key: 'min_quantity' | 'max_quantity' | 'min_weight' | 'max_weight' | 'min_sales' | 'max_sales',
|
||||
raw: string
|
||||
) => {
|
||||
const converted = persianToEnglish(raw);
|
||||
const numeric = parseFormattedNumber(converted);
|
||||
handleTempFilterChange(key, numeric || undefined);
|
||||
};
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
setFilters({
|
||||
...tempFilters,
|
||||
offset: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
offset: (page - 1) * (prev.limit || 50),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
const defaultDates = getDefaultDateRange();
|
||||
const clearedFilters = {
|
||||
from: defaultDates.from,
|
||||
to: defaultDates.to,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
};
|
||||
setTempFilters(clearedFilters);
|
||||
setFilters(clearedFilters);
|
||||
};
|
||||
|
||||
const columns: TableColumn[] = useMemo(() => [
|
||||
{
|
||||
key: 'product_name',
|
||||
label: 'نام محصول',
|
||||
align: 'right',
|
||||
render: (_val, row: any) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{row.image_url && (
|
||||
<img
|
||||
src={row.image_url}
|
||||
alt={row.product_name}
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium">{row.product_name}</div>
|
||||
{row.product_sku && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">SKU: {row.product_sku}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'total_quantity',
|
||||
label: 'تعداد فروش',
|
||||
align: 'right',
|
||||
render: (val: number) => formatWithThousands(val),
|
||||
},
|
||||
{
|
||||
key: 'total_weight',
|
||||
label: 'وزن خالص (گرم)',
|
||||
align: 'right',
|
||||
render: (val: number) => formatWithThousands(val, 2),
|
||||
},
|
||||
{
|
||||
key: 'total_final_weight',
|
||||
label: 'وزن با اجرت (گرم)',
|
||||
align: 'right',
|
||||
render: (val: number) => formatWithThousands(val, 2),
|
||||
},
|
||||
{
|
||||
key: 'total_sales_amount',
|
||||
label: 'مجموع فروش',
|
||||
align: 'right',
|
||||
render: (val: number) => formatCurrency(val),
|
||||
},
|
||||
{
|
||||
key: 'average_price',
|
||||
label: 'میانگین قیمت',
|
||||
align: 'right',
|
||||
render: (val: number) => formatCurrency(val),
|
||||
},
|
||||
{
|
||||
key: 'average_weight',
|
||||
label: 'میانگین وزن',
|
||||
align: 'right',
|
||||
render: (val: number) => formatWithThousands(val, 2) + ' گرم',
|
||||
},
|
||||
{
|
||||
key: 'variant_count',
|
||||
label: 'تعداد Variant',
|
||||
align: 'right',
|
||||
render: (val: number) => formatWithThousands(val),
|
||||
},
|
||||
], []);
|
||||
|
||||
const tableData = (data?.products_breakdown || []).map(product => ({
|
||||
...product,
|
||||
total_quantity: product.total_quantity,
|
||||
total_weight: product.total_weight,
|
||||
total_final_weight: product.total_final_weight,
|
||||
total_sales_amount: product.total_sales_amount,
|
||||
average_price: product.average_price,
|
||||
average_weight: product.average_weight,
|
||||
variant_count: product.variant_count,
|
||||
}));
|
||||
|
||||
const currentPage = Math.floor((filters.offset || 0) / (filters.limit || 50)) + 1;
|
||||
const totalPages = data?.products_pagination ? Math.ceil(data.products_pagination.total / (filters.limit || 50)) : 1;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<ReportSkeleton />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">خطا در بارگذاری گزارش</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageTitle>گزارش خلاصه فروش</PageTitle>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5 text-gray-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleApplyFilters}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
اعمال فیلترها
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleClearFilters}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
پاک کردن فیلترها
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
تاریخ شروع (الزامی)
|
||||
</label>
|
||||
<JalaliDateTimePicker
|
||||
value={tempFilters.from}
|
||||
onChange={(value) => handleDateChange('from', value)}
|
||||
placeholder="انتخاب تاریخ شروع"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
تاریخ پایان (الزامی)
|
||||
</label>
|
||||
<JalaliDateTimePicker
|
||||
value={tempFilters.to}
|
||||
onChange={(value) => handleDateChange('to', value)}
|
||||
placeholder="انتخاب تاریخ پایان"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
SKU محصول
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.product_sku || ''}
|
||||
onChange={(e) => handleTempFilterChange('product_sku', e.target.value || undefined)}
|
||||
placeholder="مثلاً RING-001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
نام محصول
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.product_name || ''}
|
||||
onChange={(e) => handleTempFilterChange('product_name', e.target.value || undefined)}
|
||||
placeholder="مثلاً انگشتر"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
حداقل تعداد
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.min_quantity?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('min_quantity', e.target.value)}
|
||||
placeholder="مثلاً ۱۰"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
حداکثر تعداد
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.max_quantity?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('max_quantity', e.target.value)}
|
||||
placeholder="مثلاً ۱۰۰"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
حداقل وزن (گرم)
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.min_weight?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('min_weight', e.target.value)}
|
||||
placeholder="مثلاً ۵.۵"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
حداکثر وزن (گرم)
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.max_weight?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('max_weight', e.target.value)}
|
||||
placeholder="مثلاً ۵۰"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
حداقل فروش (تومان)
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.min_sales?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('min_sales', e.target.value)}
|
||||
placeholder="مثلاً ۱۰۰۰۰۰۰"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
حداکثر فروش (تومان)
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.max_sales?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('max_sales', e.target.value)}
|
||||
placeholder="مثلاً ۵۰۰۰۰۰۰۰"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<DollarSign className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">مجموع فروش</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatCurrency(data.total_sales_amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
|
||||
<Package className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">وزن خالص طلا</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(data.total_gold_weight, 2)} گرم
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
|
||||
<ShoppingCart className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">تعداد سفارشات</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(data.total_orders)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
|
||||
<TrendingUp className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">میانگین سفارش</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatCurrency(data.average_order_value)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
تفکیک محصولات
|
||||
</h3>
|
||||
<Table columns={columns} data={tableData} />
|
||||
{data.products_pagination && totalPages > 1 && (
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
itemsPerPage={filters.limit || 50}
|
||||
totalItems={data.products_pagination.total}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesSummaryReportPage;
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { QUERY_KEYS } from "@/utils/query-key";
|
||||
import { getVariantComparisonReport } from "./_requests";
|
||||
import { VariantComparisonFilters } from "./_models";
|
||||
|
||||
export const useVariantComparisonReport = (filters?: VariantComparisonFilters) => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_VARIANT_COMPARISON_REPORT, filters],
|
||||
queryFn: () => getVariantComparisonReport(filters),
|
||||
});
|
||||
};
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
export interface VariantComparisonFilters {
|
||||
product_id?: number;
|
||||
product_sku?: string;
|
||||
variant_color?: string;
|
||||
variant_size?: string;
|
||||
enabled?: boolean;
|
||||
min_stock?: number;
|
||||
max_stock?: number;
|
||||
has_stock?: boolean;
|
||||
min_fee_difference?: number;
|
||||
max_fee_difference?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface VariantComparisonItem {
|
||||
variant_id: number;
|
||||
product_sku: string | null;
|
||||
product_name: string;
|
||||
variant_size: string | null;
|
||||
variant_color: string | null;
|
||||
weight: number;
|
||||
fee_percentage: number;
|
||||
factory_fee_percentage: number;
|
||||
stock_number: number;
|
||||
fee_difference: number;
|
||||
image_url: string | null;
|
||||
thumbnail_url: string | null;
|
||||
}
|
||||
|
||||
export interface VariantComparisonSummary {
|
||||
total_variants: number;
|
||||
total_stock_quantity: number;
|
||||
average_fee_percentage: number;
|
||||
average_factory_fee_percentage: number;
|
||||
average_fee_difference: number;
|
||||
variants_with_higher_fee: number;
|
||||
variants_with_lower_fee: number;
|
||||
variants_with_equal_fee: number;
|
||||
max_fee_difference: number;
|
||||
min_fee_difference: number;
|
||||
total_weight: number;
|
||||
average_weight: number;
|
||||
}
|
||||
|
||||
export interface VariantComparisonResponse {
|
||||
variants: VariantComparisonItem[];
|
||||
summary: VariantComparisonSummary;
|
||||
total: number;
|
||||
has_more: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService";
|
||||
import { API_ROUTES } from "@/constant/routes";
|
||||
import { VariantComparisonFilters, VariantComparisonResponse } from "./_models";
|
||||
|
||||
export const getVariantComparisonReport = async (
|
||||
filters?: VariantComparisonFilters
|
||||
): Promise<VariantComparisonResponse> => {
|
||||
const queryParams: Record<string, string | number | boolean> = {};
|
||||
|
||||
if (filters?.product_id !== undefined) queryParams.product_id = filters.product_id;
|
||||
if (filters?.product_sku) queryParams.product_sku = filters.product_sku;
|
||||
if (filters?.variant_color) queryParams.variant_color = filters.variant_color;
|
||||
if (filters?.variant_size) queryParams.variant_size = filters.variant_size;
|
||||
if (filters?.enabled !== undefined) queryParams.enabled = filters.enabled;
|
||||
if (filters?.min_stock !== undefined) queryParams.min_stock = filters.min_stock;
|
||||
if (filters?.max_stock !== undefined) queryParams.max_stock = filters.max_stock;
|
||||
if (filters?.has_stock !== undefined) queryParams.has_stock = filters.has_stock;
|
||||
if (filters?.min_fee_difference !== undefined) queryParams.min_fee_difference = filters.min_fee_difference;
|
||||
if (filters?.max_fee_difference !== undefined) queryParams.max_fee_difference = filters.max_fee_difference;
|
||||
if (filters?.limit !== undefined) queryParams.limit = filters.limit;
|
||||
if (filters?.offset !== undefined) queryParams.offset = filters.offset;
|
||||
|
||||
const response = await httpGetRequest<VariantComparisonResponse>(
|
||||
APIUrlGenerator(API_ROUTES.VARIANT_COMPARISON_REPORT, queryParams)
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
|
@ -1,395 +0,0 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { useVariantComparisonReport } from '../core/_hooks';
|
||||
import { VariantComparisonFilters } from '../core/_models';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Table } from '@/components/ui/Table';
|
||||
import { TableColumn } from '@/types';
|
||||
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
||||
import { Pagination } from '@/components/ui/Pagination';
|
||||
import { Filter, TrendingUp, TrendingDown, Package, DollarSign, X, Image as ImageIcon } from 'lucide-react';
|
||||
import { formatWithThousands, parseFormattedNumber, persianToEnglish } from '@/utils/numberUtils';
|
||||
import { formatCurrency } from '@/utils/formatters';
|
||||
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
|
||||
|
||||
const VariantComparisonReportPage = () => {
|
||||
const [filters, setFilters] = useState<VariantComparisonFilters>({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const [tempFilters, setTempFilters] = useState<VariantComparisonFilters>({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const { data, isLoading, error } = useVariantComparisonReport(filters);
|
||||
|
||||
const handleTempFilterChange = (key: keyof VariantComparisonFilters, value: any) => {
|
||||
setTempFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
setFilters({
|
||||
...tempFilters,
|
||||
offset: 0,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
const clearedFilters = {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
};
|
||||
setTempFilters(clearedFilters);
|
||||
setFilters(clearedFilters);
|
||||
};
|
||||
|
||||
const handleNumericFilterChange = (
|
||||
key: 'product_id' | 'min_stock' | 'max_stock' | 'min_fee_difference' | 'max_fee_difference',
|
||||
raw: string
|
||||
) => {
|
||||
const converted = persianToEnglish(raw);
|
||||
const numeric = parseFormattedNumber(converted);
|
||||
handleTempFilterChange(key, numeric || undefined);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
offset: (page - 1) * (prev.limit || 50),
|
||||
}));
|
||||
};
|
||||
|
||||
const columns: TableColumn[] = useMemo(() => [
|
||||
{
|
||||
key: 'product_name',
|
||||
label: 'محصول',
|
||||
align: 'right',
|
||||
render: (_val, row: any) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{row.image_url && (
|
||||
<img
|
||||
src={row.image_url}
|
||||
alt={row.product_name}
|
||||
className="w-10 h-10 object-cover rounded"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium">{row.product_name}</div>
|
||||
{row.product_sku && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">SKU: {row.product_sku}</div>
|
||||
)}
|
||||
{(row.variant_size || row.variant_color) && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{row.variant_size && `سایز: ${row.variant_size}`}
|
||||
{row.variant_size && row.variant_color && ' - '}
|
||||
{row.variant_color && `رنگ: ${row.variant_color}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'weight',
|
||||
label: 'وزن (گرم)',
|
||||
align: 'right',
|
||||
render: (val: number) => formatWithThousands(val, 2),
|
||||
},
|
||||
{
|
||||
key: 'fee_percentage',
|
||||
label: 'درصد اجرت مشتری',
|
||||
align: 'right',
|
||||
render: (val: number) => `${formatWithThousands(val, 1)}%`,
|
||||
},
|
||||
{
|
||||
key: 'factory_fee_percentage',
|
||||
label: 'درصد اجرت کارخانه',
|
||||
align: 'right',
|
||||
render: (val: number) => `${formatWithThousands(val, 1)}%`,
|
||||
},
|
||||
{
|
||||
key: 'fee_difference',
|
||||
label: 'سود اجرت',
|
||||
align: 'right',
|
||||
render: (val: number) => (
|
||||
<span className={val >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
|
||||
{val >= 0 ? '+' : ''}{formatWithThousands(val, 1)}%
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'stock_number',
|
||||
label: 'موجودی',
|
||||
align: 'right',
|
||||
render: (val: number) => formatWithThousands(val),
|
||||
},
|
||||
], []);
|
||||
|
||||
const tableData = data?.variants || [];
|
||||
const limit = filters.limit || 50;
|
||||
const currentPage = Math.floor((filters.offset || 0) / limit) + 1;
|
||||
const totalPages = data?.total ? Math.ceil(data.total / limit) : 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<ReportSkeleton />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">خطا در بارگذاری گزارش</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageTitle>گزارش مقایسه Variantها</PageTitle>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5 text-gray-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleApplyFilters}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
اعمال فیلترها
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleClearFilters}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
پاک کردن فیلترها
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
شناسه محصول
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.product_id?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('product_id', e.target.value)}
|
||||
placeholder="مثلاً ۱۲۳"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
SKU محصول
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.product_sku || ''}
|
||||
onChange={(e) => handleTempFilterChange('product_sku', e.target.value || undefined)}
|
||||
placeholder="مثلاً RING-001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
رنگ Variant
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.variant_color || ''}
|
||||
onChange={(e) => handleTempFilterChange('variant_color', e.target.value || undefined)}
|
||||
placeholder="مثلاً زرد"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
سایز Variant
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.variant_size || ''}
|
||||
onChange={(e) => handleTempFilterChange('variant_size', e.target.value || undefined)}
|
||||
placeholder="مثلاً ۱۸"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
وضعیت فعال
|
||||
</label>
|
||||
<select
|
||||
value={tempFilters.enabled === undefined ? '' : tempFilters.enabled.toString()}
|
||||
onChange={(e) => handleTempFilterChange('enabled', e.target.value === '' ? undefined : e.target.value === 'true')}
|
||||
className="input"
|
||||
>
|
||||
<option value="">همه</option>
|
||||
<option value="true">فعال</option>
|
||||
<option value="false">غیرفعال</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
حداقل موجودی
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.min_stock?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('min_stock', e.target.value)}
|
||||
placeholder="مثلاً ۵"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
حداکثر موجودی
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.max_stock?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('max_stock', e.target.value)}
|
||||
placeholder="مثلاً ۱۰۰"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
فقط با موجودی
|
||||
</label>
|
||||
<select
|
||||
value={tempFilters.has_stock === undefined ? '' : tempFilters.has_stock.toString()}
|
||||
onChange={(e) => handleTempFilterChange('has_stock', e.target.value === '' ? undefined : e.target.value === 'true')}
|
||||
className="input"
|
||||
>
|
||||
<option value="">همه</option>
|
||||
<option value="true">بله</option>
|
||||
<option value="false">خیر</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
حداقل سود اجرت (%)
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.min_fee_difference?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('min_fee_difference', e.target.value)}
|
||||
placeholder="مثلاً ۲.۵"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
حداکثر سود اجرت (%)
|
||||
</label>
|
||||
<Input
|
||||
value={tempFilters.max_fee_difference?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('max_fee_difference', e.target.value)}
|
||||
placeholder="مثلاً ۱۰"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<Package className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">کل Variantها</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(data.summary.total_variants)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
|
||||
<TrendingUp className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Variantهای سودآور</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(data.summary.variants_with_higher_fee)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-red-100 dark:bg-red-900 rounded-lg">
|
||||
<TrendingDown className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Variantهای زیانده</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(data.summary.variants_with_lower_fee)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
|
||||
<DollarSign className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">میانگین سود اجرت</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(data.summary.average_fee_difference, 1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
لیست Variantها
|
||||
</h3>
|
||||
<Table columns={columns} data={tableData} />
|
||||
{data && data.total !== undefined && data.total > 0 && totalPages > 1 && (
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
itemsPerPage={limit}
|
||||
totalItems={data.total}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default VariantComparisonReportPage;
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Settings as SettingsIcon, UserCheck, UserX } from 'lucide-react';
|
||||
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { useAutoVerifySetting, useUpdateAutoVerifySetting } from './core/_hooks';
|
||||
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
|
||||
|
||||
const SystemSettingsPage = () => {
|
||||
const { data, isLoading, error } = useAutoVerifySetting();
|
||||
const { mutate: updateSetting, isPending } = useUpdateAutoVerifySetting();
|
||||
|
||||
const handleToggle = (enabled: boolean) => {
|
||||
updateSetting({
|
||||
data: { enabled },
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<ReportSkeleton />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 dark:text-red-400">
|
||||
خطا در بارگذاری تنظیمات
|
||||
</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const isEnabled = data?.setting?.data?.enabled || false;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageTitle>تنظیمات سیستم</PageTitle>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{isEnabled ? (
|
||||
<UserCheck className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<UserX className="h-6 w-6 text-gray-400" />
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
تأیید خودکار کاربران جدید
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mr-9">
|
||||
در صورت فعال بودن این گزینه، کاربرانی که برای اولین بار ثبتنام میکنند به صورت خودکار تأیید میشوند.
|
||||
در غیر این صورت، کاربران باید توسط ادمین به صورت دستی تأیید شوند.
|
||||
</p>
|
||||
{data?.setting?.updated_at && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2 mr-9">
|
||||
آخرین بهروزرسانی: {new Date(data.setting.updated_at).toLocaleDateString('fa-IR')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{isPending ? (
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900 dark:border-gray-100"></div>
|
||||
) : (
|
||||
<ToggleSwitch
|
||||
checked={isEnabled}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemSettingsPage;
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { QUERY_KEYS } from "@/utils/query-key";
|
||||
import {
|
||||
getAutoVerifySetting,
|
||||
updateAutoVerifySetting,
|
||||
} from "./_requests";
|
||||
import {
|
||||
UpdateAutoVerifySettingRequest,
|
||||
AutoVerifySettingResponse,
|
||||
} from "./_models";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export const useAutoVerifySetting = () => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_AUTO_VERIFY_SETTING],
|
||||
queryFn: getAutoVerifySetting,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateAutoVerifySetting = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: [QUERY_KEYS.UPDATE_AUTO_VERIFY_SETTING],
|
||||
mutationFn: (data: UpdateAutoVerifySettingRequest) =>
|
||||
updateAutoVerifySetting(data),
|
||||
onSuccess: (data: AutoVerifySettingResponse) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QUERY_KEYS.GET_AUTO_VERIFY_SETTING],
|
||||
});
|
||||
toast.success(
|
||||
`تأیید خودکار کاربران جدید ${data.setting.data.enabled ? "فعال" : "غیرفعال"} شد`
|
||||
);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("Error updating auto verify setting:", error);
|
||||
toast.error(error?.message || "خطا در بهروزرسانی تنظیمات");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
export interface AutoVerifySettingData {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AutoVerifySetting {
|
||||
id: number;
|
||||
name: string;
|
||||
data: AutoVerifySettingData;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AutoVerifySettingResponse {
|
||||
setting: AutoVerifySetting;
|
||||
}
|
||||
|
||||
export interface UpdateAutoVerifySettingRequest {
|
||||
data: AutoVerifySettingData;
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { httpGetRequest, httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService";
|
||||
import { API_ROUTES } from "@/constant/routes";
|
||||
import {
|
||||
AutoVerifySettingResponse,
|
||||
UpdateAutoVerifySettingRequest,
|
||||
} from "./_models";
|
||||
|
||||
export const getAutoVerifySetting = async (): Promise<AutoVerifySettingResponse> => {
|
||||
const response = await httpGetRequest<AutoVerifySettingResponse>(
|
||||
APIUrlGenerator(API_ROUTES.GET_AUTO_VERIFY_SETTING)
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateAutoVerifySetting = async (
|
||||
data: UpdateAutoVerifySettingRequest
|
||||
): Promise<AutoVerifySettingResponse> => {
|
||||
const response = await httpPostRequest<AutoVerifySettingResponse>(
|
||||
APIUrlGenerator(API_ROUTES.UPDATE_AUTO_VERIFY_SETTING),
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
export type WalletType = "rial" | "gold18k";
|
||||
|
||||
export interface WalletCreditRequest {
|
||||
user_id: number;
|
||||
wallet_type: WalletType;
|
||||
amount: number;
|
||||
reason: string;
|
||||
admin_note?: string;
|
||||
}
|
||||
|
||||
export interface WalletCreditResponse {
|
||||
transaction_id: number;
|
||||
audit_log_id: number;
|
||||
new_balance: number;
|
||||
message: string;
|
||||
}
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { QUERY_KEYS } from "@/utils/query-key";
|
||||
import toast from "react-hot-toast";
|
||||
import { getWalletStatus, updateWalletStatus, creditWallet } from "./_requests";
|
||||
import { getWalletStatus, updateWalletStatus } from "./_requests";
|
||||
import { UpdateWalletStatusRequest } from "./_models";
|
||||
import { WalletCreditRequest } from "./_credit-models";
|
||||
|
||||
export const useWalletStatus = () => {
|
||||
return useQuery({
|
||||
|
|
@ -29,20 +28,6 @@ export const useUpdateWalletStatus = () => {
|
|||
});
|
||||
};
|
||||
|
||||
export const useWalletCredit = () => {
|
||||
return useMutation({
|
||||
mutationKey: [QUERY_KEYS.WALLET_CREDIT],
|
||||
mutationFn: (payload: WalletCreditRequest) => creditWallet(payload),
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message || "کیف پول با موفقیت شارژ شد");
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("Error crediting wallet:", error);
|
||||
toast.error(error?.message || "خطا در شارژ کیف پول");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { httpGetRequest, httpPutRequest, httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService";
|
||||
import { httpGetRequest, httpPutRequest, APIUrlGenerator } from "@/utils/baseHttpService";
|
||||
import { API_ROUTES } from "@/constant/routes";
|
||||
import { WalletStatusResponse, UpdateWalletStatusRequest, UpdateWalletStatusResponse } from "./_models";
|
||||
import { WalletCreditRequest, WalletCreditResponse } from "./_credit-models";
|
||||
|
||||
export const getWalletStatus = async (): Promise<WalletStatusResponse> => {
|
||||
const response = await httpGetRequest<WalletStatusResponse>(
|
||||
|
|
@ -20,16 +19,6 @@ export const updateWalletStatus = async (
|
|||
return response.data;
|
||||
};
|
||||
|
||||
export const creditWallet = async (
|
||||
payload: WalletCreditRequest
|
||||
): Promise<WalletCreditResponse> => {
|
||||
const response = await httpPostRequest<WalletCreditResponse>(
|
||||
APIUrlGenerator(API_ROUTES.WALLET_CREDIT),
|
||||
payload
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,180 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import { Wallet, Plus, Loader2 } from 'lucide-react';
|
||||
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useWalletCredit } from '../core/_hooks';
|
||||
import { WalletCreditRequest, WalletType } from '../core/_credit-models';
|
||||
import { formatCurrency } from '@/utils/formatters';
|
||||
import { persianToEnglish } from '@/utils/numberUtils';
|
||||
|
||||
const schema = yup.object({
|
||||
user_id: yup.number().required('شناسه کاربر الزامی است').positive('شناسه کاربر باید عدد مثبت باشد'),
|
||||
wallet_type: yup.string().oneOf(['rial', 'gold18k'], 'نوع کیف پول نامعتبر است').required('نوع کیف پول الزامی است'),
|
||||
amount: yup.number().required('مبلغ الزامی است').positive('مبلغ باید عدد مثبت باشد'),
|
||||
reason: yup.string().required('دلیل شارژ الزامی است').min(10, 'دلیل باید حداقل ۱۰ کاراکتر باشد'),
|
||||
admin_note: yup.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = yup.InferType<typeof schema>;
|
||||
|
||||
const WalletCreditPage = () => {
|
||||
const { mutate: creditWallet, isPending } = useWalletCredit();
|
||||
const [successData, setSuccessData] = useState<{ new_balance: number; message: string } | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
} = useForm<FormData>({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
wallet_type: 'rial',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
const payload: WalletCreditRequest = {
|
||||
user_id: data.user_id,
|
||||
wallet_type: data.wallet_type as WalletType,
|
||||
amount: data.amount,
|
||||
reason: data.reason,
|
||||
admin_note: data.admin_note || undefined,
|
||||
};
|
||||
|
||||
creditWallet(payload, {
|
||||
onSuccess: (response) => {
|
||||
setSuccessData({
|
||||
new_balance: response.new_balance,
|
||||
message: response.message,
|
||||
});
|
||||
reset();
|
||||
setTimeout(() => setSuccessData(null), 5000);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageTitle>شارژ کیف پول کاربر</PageTitle>
|
||||
|
||||
{successData && (
|
||||
<div className="mb-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<p className="text-green-800 dark:text-green-200 font-medium mb-2">
|
||||
{successData.message}
|
||||
</p>
|
||||
<p className="text-sm text-green-700 dark:text-green-300">
|
||||
موجودی جدید: {formatCurrency(successData.new_balance)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
شناسه کاربر <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
{...register('user_id', {
|
||||
setValueAs: (value) => (value === '' ? undefined : Number(persianToEnglish(value))),
|
||||
})}
|
||||
error={errors.user_id?.message}
|
||||
placeholder="مثلاً 52"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
نوع کیف پول <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
{...register('wallet_type')}
|
||||
className={`input ${errors.wallet_type ? 'border-red-500' : ''}`}
|
||||
>
|
||||
<option value="rial">ریال</option>
|
||||
<option value="gold18k">طلا ۱۸ عیار</option>
|
||||
</select>
|
||||
{errors.wallet_type && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||
{errors.wallet_type.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
مبلغ (تومان) <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
{...register('amount', {
|
||||
setValueAs: (value) => (value === '' ? undefined : Number(persianToEnglish(value))),
|
||||
})}
|
||||
error={errors.amount?.message}
|
||||
placeholder="مثلاً 1000000"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
دلیل شارژ <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
{...register('reason')}
|
||||
rows={3}
|
||||
className={`input resize-none ${errors.reason ? 'border-red-500' : ''}`}
|
||||
placeholder="مثلاً: شارژ کیف پول کاربر به علت مرجوع کردن یک سفارش به مبلغ ۱ میلیون"
|
||||
/>
|
||||
{errors.reason && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||
{errors.reason.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
یادداشت ادمین (اختیاری)
|
||||
</label>
|
||||
<textarea
|
||||
{...register('admin_note')}
|
||||
rows={2}
|
||||
className="input resize-none"
|
||||
placeholder="مثلاً: شارژ کیف طلا"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => reset()}
|
||||
disabled={isPending}
|
||||
>
|
||||
پاک کردن
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
loading={isPending}
|
||||
>
|
||||
<Plus className="h-4 w-4 ml-2" />
|
||||
شارژ کیف پول
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default WalletCreditPage;
|
||||
|
|
@ -143,25 +143,9 @@ export const QUERY_KEYS = {
|
|||
GET_SALES_GROWTH_REPORT: "get_sales_growth_report",
|
||||
GET_USER_REGISTRATION_GROWTH_REPORT: "get_user_registration_growth_report",
|
||||
GET_SALES_BY_CATEGORY_REPORT: "get_sales_by_category_report",
|
||||
GET_SALES_SUMMARY_REPORT: "get_sales_summary_report",
|
||||
GET_PROFIT_LOSS_REPORT: "get_profit_loss_report",
|
||||
GET_INVENTORY_VALUE_REPORT: "get_inventory_value_report",
|
||||
GET_VARIANT_COMPARISON_REPORT: "get_variant_comparison_report",
|
||||
|
||||
// System Settings
|
||||
GET_AUTO_VERIFY_SETTING: "get_auto_verify_setting",
|
||||
UPDATE_AUTO_VERIFY_SETTING: "update_auto_verify_setting",
|
||||
|
||||
// Wallet Credit
|
||||
WALLET_CREDIT: "wallet_credit",
|
||||
|
||||
// Product Comments
|
||||
GET_PRODUCT_COMMENTS: "get_product_comments",
|
||||
UPDATE_COMMENT_STATUS: "update_comment_status",
|
||||
DELETE_COMMENT: "delete_comment",
|
||||
|
||||
// Admin Notifications
|
||||
GET_ADMIN_NOTIFICATIONS: "get_admin_notifications",
|
||||
GET_ADMIN_NOTIFICATIONS_UNREAD: "get_admin_notifications_unread",
|
||||
GET_ADMIN_NOTIFICATIONS_COUNT: "get_admin_notifications_count",
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue