Android_Retail/src/Pages/DashBoard/TopSellingProductsChart.jsx

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;