admin/src/pages/reports/sales-summary/sales-summary-report/SalesSummaryReportPage.tsx

441 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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">کد محصول: {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">
کد محصول
</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;