441 lines
15 KiB
TypeScript
441 lines
15 KiB
TypeScript
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;
|