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