562 lines
20 KiB
JavaScript
562 lines
20 KiB
JavaScript
import {
|
|
useState,
|
|
useEffect,
|
|
useRef,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
lazy,
|
|
Suspense,
|
|
} from 'react';
|
|
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
|
|
import { TrendingUp, Package, IndianRupee } from 'lucide-react';
|
|
import './TopSellingProductsChart.scss';
|
|
import { useDispatch } from 'react-redux';
|
|
import { getSession } from '../../Services/Others';
|
|
import { changeBreadCrumb } from '../../Features/AppPage/CenterPage';
|
|
|
|
import dayjs from 'dayjs';
|
|
|
|
const DropDowns = lazy(() =>
|
|
import('../../Components/Forms/DropDown').then((module) => ({
|
|
default: module.DropDowns,
|
|
}))
|
|
);
|
|
|
|
const subDirectory = import.meta.env.BASE_URL;
|
|
|
|
const items = [
|
|
{
|
|
name: 'Home',
|
|
link: `${subDirectory}app-page/home`,
|
|
},
|
|
];
|
|
|
|
const TopSellingProducts = ({
|
|
dates = [null, null],
|
|
products = [],
|
|
loading = false,
|
|
totalPages,
|
|
page = 1,
|
|
setPage = () => {},
|
|
fetchProducts = () => {},
|
|
initialDate = true,
|
|
}) => {
|
|
const today = new Date();
|
|
const year = today.getFullYear();
|
|
const month = today.getMonth();
|
|
|
|
const formatDate = (date) =>
|
|
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
|
|
const intialFromDate = `${year}-${String(month + 1).padStart(2, '0')}-01`;
|
|
const intialToDate = formatDate(today);
|
|
|
|
const dispatch = useDispatch();
|
|
|
|
const AppId = getSession('AppId');
|
|
const CompId = getSession('CompId');
|
|
const BranchId = getSession('BranchId');
|
|
|
|
const [chartType, setChartType] = useState('bar');
|
|
const [metric, setMetric] = useState('sales');
|
|
const chartWrapperRef = useRef(null);
|
|
const [containerWidth, setContainerWidth] = useState(800); // fallback
|
|
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
|
|
const [hoveredItem, setHoveredItem] = useState(null);
|
|
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
|
|
|
console.log(products?.[0]?.TotalCount, 'products');
|
|
|
|
const options = useMemo(() => {
|
|
const total = products?.[0]?.TotalCount || 0;
|
|
const perPage = 10;
|
|
|
|
const totalPages = Math.ceil(total / perPage);
|
|
|
|
const arr = [];
|
|
|
|
for (let page = 1; page <= totalPages; page++) {
|
|
const start = (page - 1) * perPage + 1;
|
|
const end = Math.min(page * perPage, total);
|
|
|
|
arr.push({
|
|
label: `${start}-${end}`,
|
|
value: page,
|
|
});
|
|
}
|
|
|
|
return arr;
|
|
}, [products?.[0]?.TotalCount]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!chartWrapperRef.current) return;
|
|
const el = chartWrapperRef.current;
|
|
|
|
// set initial width
|
|
setContainerWidth(el.clientWidth || 800);
|
|
|
|
// ResizeObserver to update container width precisely
|
|
const ro = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const w = Math.floor(entry.contentRect.width);
|
|
if (w && w !== containerWidth) setContainerWidth(w);
|
|
}
|
|
});
|
|
ro.observe(el);
|
|
|
|
return () => ro.disconnect();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [chartWrapperRef.current]);
|
|
// breakpoints
|
|
const mobileBreakpoint = 430; // you said < 430px is problematic
|
|
const isNarrow = containerWidth <= mobileBreakpoint;
|
|
|
|
// compute sizes based on actual container width
|
|
// make pie fill a good portion of container (leave some padding)
|
|
const pieOuterRadius = Math.max(
|
|
48,
|
|
Math.floor(containerWidth * (isNarrow ? 0.36 : 0.28))
|
|
);
|
|
const pieInnerRadius = Math.floor(pieOuterRadius * (isNarrow ? 0.5 : 0.55));
|
|
|
|
// height for ResponsiveContainer: tether to containerWidth so chart stays proportional
|
|
const responsiveHeight = Math.max(
|
|
220,
|
|
Math.floor(containerWidth * (isNarrow ? 0.62 : 0.5))
|
|
);
|
|
|
|
// tweak minAngle: small screens should allow smaller minAngle so slices remain visible
|
|
const minAngle = isNarrow ? 4 : 8;
|
|
|
|
console.log(pieOuterRadius, 'pieOuterRadius');
|
|
|
|
// const COLORS = ['#2563eb', '#7c3aed', '#db2777', '#ea580c', '#16a34a', '#0891b2', '#d97706', '#65a30d', '#4f46e5', '#0d9488'];
|
|
const COLORS = [
|
|
'#4f8df5', // lighter blue (was #2563eb)
|
|
'#9a6df3', // lighter violet (was #7c3aed)
|
|
'#e95b96', // lighter pink (was #db2777)
|
|
'#f48b3c', // lighter orange (was #ea580c)
|
|
'#34c86a', // lighter green (was #16a34a)
|
|
'#33bcd4', // lighter cyan (was #0891b2)
|
|
'#e39a1a', // lighter amber (was #d97706)
|
|
'#8bc329', // lighter lime (was #65a30d)
|
|
'#6d63ef', // lighter indigo (was #4f46e5)
|
|
'#35bfa9', // lighter teal (was #0d9488)
|
|
];
|
|
|
|
useEffect(() => {
|
|
try {
|
|
dispatch(changeBreadCrumb({ items: items }));
|
|
} catch (error) {
|
|
console.log(error?.message, 'error displaying breadcrumbs');
|
|
}
|
|
}, []);
|
|
|
|
const CustomTooltip = ({ active, payload }) => {
|
|
if (active && payload && payload?.[0]) {
|
|
const product = payload?.[0]?.payload;
|
|
return (
|
|
<div className="custom-tooltip">
|
|
<p className="tooltip-title">
|
|
{product?.ProdName} - {product?.UOMName}
|
|
</p>
|
|
{product?.BrandName ? (
|
|
<p className="tooltip-brand">Brand: {product?.BrandName}</p>
|
|
) : null}
|
|
<div className="tooltip-divider">
|
|
<p className="tooltip-subtitle">Variants:</p>
|
|
{product?.variantDetails?.map((variant, idx) => (
|
|
<div key={idx} className="tooltip-variant">
|
|
<span className="variant-name">
|
|
{variant?.ProdVariantName}:
|
|
</span>
|
|
<span className="variant-data">
|
|
{variant?.SalesCount} sold | ₹{variant?.Revenue}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="tooltip-total">
|
|
<div className="total-row">
|
|
<span>Total Sales:</span>
|
|
<span>{product?.TotalSoldQty}</span>
|
|
</div>
|
|
<div className="total-row">
|
|
<span>Total Revenue:</span>
|
|
<span>₹{product?.TotalRevenue}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const renderPercentInside = ({
|
|
cx,
|
|
cy,
|
|
midAngle,
|
|
innerRadius,
|
|
outerRadius,
|
|
percent,
|
|
value,
|
|
}) => {
|
|
const percentValue = Math.round(percent * 100);
|
|
// hide labels for very small percentages on narrow screens
|
|
if (percentValue < (isNarrow ? 3 : 2)) return null;
|
|
|
|
const RADIAN = Math.PI / 180;
|
|
// place label inside slice (closer to inner radius on narrow screens)
|
|
const radius =
|
|
innerRadius + (outerRadius - innerRadius) * (isNarrow ? 0.55 : 0.6);
|
|
|
|
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
|
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
|
|
|
const fontSize = isNarrow ? 10 : 12;
|
|
|
|
// choose contrasting color: white usually works on colored slices
|
|
return (
|
|
<text
|
|
x={x}
|
|
y={y}
|
|
fill="#fff"
|
|
fontSize={fontSize}
|
|
fontWeight="700"
|
|
textAnchor="middle"
|
|
dominantBaseline="central"
|
|
pointerEvents="none"
|
|
style={{ textShadow: '0px 1px 2px rgba(0,0,0,0.35)' }}
|
|
>
|
|
{/* {`${percentValue}%`} */}
|
|
{value}
|
|
</text>
|
|
);
|
|
};
|
|
const getBenchmarkValue = (maxValue) => {
|
|
if (maxValue <= 0) return 100;
|
|
|
|
const benchmarks = [
|
|
// Fine granularity for small numbers, coarser for large numbers
|
|
1, 2, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80, 90, 100, 125,
|
|
150, 175, 200, 225, 250, 275, 300, 350, 400, 450, 500, 600, 700, 800, 900,
|
|
1000, 1250, 1500, 1750, 2000, 2500, 3000, 3500, 4000, 4500, 5000, 6000,
|
|
7000, 8000, 9000, 10000, 12500, 15000, 17500, 20000, 25000, 30000, 35000,
|
|
40000, 45000, 50000, 60000, 70000, 80000, 90000, 100000, 125000, 150000,
|
|
175000, 200000, 250000, 300000, 350000, 400000, 450000, 500000, 600000,
|
|
700000, 800000, 900000, 1000000,
|
|
];
|
|
|
|
const benchmark = benchmarks.find((b) => b >= maxValue);
|
|
return benchmark || Math.ceil(maxValue / 10000) * 10000;
|
|
};
|
|
|
|
const chartData = products?.map((p) => ({
|
|
...p,
|
|
name: p?.ProdName,
|
|
value: metric === 'sales' ? p?.TotalSoldQty : p?.TotalRevenue,
|
|
benchMarkValue:
|
|
metric === 'sales' ? p?.TotalRevenueCount : p?.TotalRevenueAmount,
|
|
}));
|
|
|
|
const benchmarkValue = getBenchmarkValue(
|
|
Math.max(...(chartData?.map((d) => d?.benchMarkValue) || [0]))
|
|
);
|
|
|
|
return (
|
|
<div className="top-selling-container">
|
|
<div className="content-wrapper">
|
|
{/* Stats Cards */}
|
|
<div className="stats-grid">
|
|
<div className="stat-card1">
|
|
<div className="stat-content">
|
|
<div className="stat-text">
|
|
<p className="stat-label">Total Products</p>
|
|
<p className="stat-value">{products?.length}</p>
|
|
</div>
|
|
<Package className="stat-icon" />
|
|
</div>
|
|
</div>
|
|
<div className="stat-card2">
|
|
<div className="stat-content">
|
|
<div className="stat-text">
|
|
<p className="stat-label">Total Sales</p>
|
|
<p className="stat-value">
|
|
{products?.reduce(
|
|
(sum, p) => sum + (p?.TotalSoldQty || 0),
|
|
0
|
|
)}
|
|
</p>
|
|
</div>
|
|
<TrendingUp className="stat-icon" />
|
|
</div>
|
|
</div>
|
|
<div className="stat-card3">
|
|
<div className="stat-content">
|
|
<div className="stat-text">
|
|
<p className="stat-label">Total Revenue</p>
|
|
<p className="stat-value">
|
|
₹
|
|
{products
|
|
?.reduce((sum, p) => sum + (p?.TotalRevenue || 0), 0)
|
|
?.toFixed(2)}
|
|
</p>
|
|
</div>
|
|
<IndianRupee className="stat-icon" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="controls-card">
|
|
<div className="controls-wrapper">
|
|
<div className="metric-controls">
|
|
<div
|
|
onClick={() => setMetric('sales')}
|
|
className={`btn-metric ${metric === 'sales' ? 'active' : ''}`}
|
|
>
|
|
<Package className="metric-icon" />
|
|
Sales Count
|
|
</div>
|
|
<div
|
|
onClick={() => setMetric('revenue')}
|
|
className={`btn-metric ${metric === 'revenue' ? 'active' : ''}`}
|
|
>
|
|
<IndianRupee className="metric-icon" />
|
|
Revenue
|
|
</div>
|
|
</div>
|
|
<div className="chart-type-controls">
|
|
<div
|
|
onClick={() => setChartType('bar')}
|
|
className={`btn-chart-type ${chartType === 'bar' ? 'active' : ''}`}
|
|
>
|
|
Bar Chart
|
|
</div>
|
|
<div
|
|
onClick={() => setChartType('pie')}
|
|
className={`btn-chart-type ${chartType === 'pie' ? 'active' : ''}`}
|
|
>
|
|
Pie Chart
|
|
</div>
|
|
</div>
|
|
|
|
<Suspense fallback={<div>Loading...</div>}>
|
|
<DropDowns
|
|
className={'item-range-dropdown'}
|
|
valueData={page}
|
|
options={options}
|
|
label={'Products'}
|
|
onChangeFunction={async (value) => {
|
|
setPage(value);
|
|
const FromDate =
|
|
dates?.[0]?.format('YYYY-MM-DD') || intialFromDate;
|
|
const ToDate =
|
|
dates?.[1]?.format('YYYY-MM-DD') || intialToDate;
|
|
await fetchProducts({
|
|
AppId,
|
|
CompId,
|
|
BranchId,
|
|
FromDate,
|
|
ToDate,
|
|
pageNumber: value,
|
|
});
|
|
}}
|
|
isOnchanges={page ? true : false}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
|
|
{(initialDate ? true : !dates && !dates?.[0] && !dates?.[1]) &&
|
|
intialFromDate &&
|
|
intialToDate && (
|
|
<div className="date-range">{`${dayjs(intialFromDate)?.format('DD/MMM/YYYY')} - ${dayjs(intialToDate)?.format('DD/MMM/YYYY')}`}</div>
|
|
)}
|
|
|
|
<div className="chart-dtls">
|
|
{/* Chart */}
|
|
<div className="chart-section">
|
|
<div className="chart-card" ref={chartWrapperRef}>
|
|
{loading && products?.length === 0 ? (
|
|
<div className="loading-container">
|
|
<div className="loading-spinner"></div>
|
|
</div>
|
|
) : chartData?.length === 0 ? (
|
|
<div className="no-data">No data available</div>
|
|
) : (
|
|
<>
|
|
{chartType === 'bar' ? (
|
|
<div className="chart-data">
|
|
<div className="chart-axis">
|
|
<div></div>
|
|
<div className="axis-points">
|
|
{[
|
|
0,
|
|
Math.round(benchmarkValue * 0.25),
|
|
Math.round(benchmarkValue * 0.5),
|
|
Math.round(benchmarkValue * 0.75),
|
|
benchmarkValue,
|
|
].map((num, i) => (
|
|
<div key={i} className="axis-label-wrapper">
|
|
<div className="axis-tick"></div>
|
|
<div className="axis-label">{num}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div></div>
|
|
</div>
|
|
{chartData?.length > 0 ? (
|
|
<>
|
|
{chartData?.map((item, index) => (
|
|
<div
|
|
key={index}
|
|
className="chart-data-item"
|
|
onMouseEnter={(e) => {
|
|
setHoveredItem(item);
|
|
setMousePosition({
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
});
|
|
}}
|
|
// style={{}}
|
|
onMouseLeave={() => setHoveredItem(null)}
|
|
onMouseMove={(e) =>
|
|
setMousePosition({ x: e.clientX, y: e.clientY })
|
|
}
|
|
>
|
|
<div className="chart-data-item-name">
|
|
<span>{`#${index + 1 + (page - 1) * 10}`}</span>
|
|
<span>{item?.name}</span>
|
|
</div>
|
|
<div
|
|
className="barcharLine"
|
|
style={{ borderRadius: '1px' }}
|
|
>
|
|
<div
|
|
className="barcharLineInside"
|
|
style={{
|
|
width: `${Math.max(5, (item?.value / benchmarkValue) * 100)}%`,
|
|
backgroundColor:
|
|
COLORS[index % COLORS.length],
|
|
}}
|
|
>
|
|
<span className="bar-percentage">
|
|
{/* {((item?.value / Math.max(...chartData.map(d => d?.value))) * 100).toFixed(1)}% */}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="chart-data-item-value">
|
|
{item?.value}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</>
|
|
) : (
|
|
<div className="chart-data">
|
|
<div className="chart-data-item">
|
|
<div className="chart-data-item-name">No Data</div>
|
|
<div className="chart-data-item-value">0</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
/* Replace your existing <Pie ...> with this */
|
|
/* PIE + RIGHT-LEGEND: replace the existing pie rendering */
|
|
<div className="pie-with-legend">
|
|
<div className="pie-wrapper">
|
|
<ResponsiveContainer
|
|
width="100%"
|
|
height={responsiveHeight}
|
|
>
|
|
<PieChart>
|
|
<Pie
|
|
data={chartData}
|
|
cx="50%"
|
|
cy="50%"
|
|
labelLine={false}
|
|
label={
|
|
windowWidth <= 600 ? false : renderPercentInside
|
|
}
|
|
// outerRadius={pieOuterRadius}
|
|
innerRadius={pieInnerRadius - 50}
|
|
paddingAngle={1}
|
|
minAngle={minAngle}
|
|
dataKey="value"
|
|
>
|
|
{chartData.map((entry, index) => (
|
|
<Cell
|
|
key={`cell-${index}`}
|
|
fill={COLORS[index % COLORS.length]}
|
|
/>
|
|
))}
|
|
</Pie>
|
|
<Tooltip content={<CustomTooltip />} />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
<div className="pie-legend">
|
|
{(() => {
|
|
const total =
|
|
chartData.reduce(
|
|
(s, d) => s + Number(d?.value || 0),
|
|
0
|
|
) || 1;
|
|
return chartData.map((d, i) => {
|
|
const color = COLORS[i % COLORS.length];
|
|
const value = Number(d?.value || 0);
|
|
const percent = total ? (value / total) * 100 : 0;
|
|
return (
|
|
<div className="legend-row" key={i}>
|
|
<span
|
|
className="legend-color"
|
|
style={{ backgroundColor: color }}
|
|
/>
|
|
<div className="legend-text">
|
|
<div className="legend-title">{d?.name}</div>
|
|
<div className="legend-sub">
|
|
<span className="legend-value">
|
|
{value}
|
|
</span>
|
|
<span
|
|
className="legend-percent"
|
|
style={{ color: color }}
|
|
>
|
|
{percent.toFixed(2)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
})()}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{hoveredItem && (
|
|
<div
|
|
style={{
|
|
position: 'fixed',
|
|
left: mousePosition.x + 10,
|
|
top: mousePosition.y - 10,
|
|
zIndex: 1000,
|
|
pointerEvents: 'none',
|
|
}}
|
|
>
|
|
<CustomTooltip active={true} payload={[{ payload: hoveredItem }]} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TopSellingProducts;
|