diff --git a/src/App.tsx b/src/App.tsx index db2e85f..a04ff30 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { isAuthenticated, isSuperAdmin } from './dataService'; const Dashboard = React.lazy(() => import('./pages/Dashboard')); const Products = React.lazy(() => import('./pages/Products')); const ProductDetails = React.lazy(() => import('./pages/ProductDetails')); +const ProductGroupDetails = React.lazy(() => import('./pages/ProductGroupDetails')); const Replenishment = React.lazy(() => import('./pages/Replenishment')); const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders')); const Clients = React.lazy(() => import('./pages/Clients')); @@ -46,6 +47,7 @@ function App() { }> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/ProductGroupDetails.tsx b/src/pages/ProductGroupDetails.tsx new file mode 100644 index 0000000..55861b3 --- /dev/null +++ b/src/pages/ProductGroupDetails.tsx @@ -0,0 +1,454 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link, useOutletContext, useParams } from 'react-router-dom'; +import { DollarSign, Package, Palette, Ruler, TrendingDown, TrendingUp, Warehouse } from 'lucide-react'; +import BackButton from '../components/BackButton'; +import DateRangePicker from '../components/DateRangePicker'; +import RefreshStatus from '../components/RefreshStatus'; +import { fetchProductAnalytics } from '../dataService'; +import { decodeProductGroupKey, normalizeProductText, parseProductName, sortProductSizes } from '../productParsing'; +import type { DateRange, ProductAnalyticsItem } from '../types'; + +type VariantRow = ProductAnalyticsItem & { + color: string; + size: string; + dailySales: number; + daysOfCover: number | null; +}; + +type BreakdownRow = { + label: string; + quantitySold: number; + revenue: number; + stock: number; + skuCount: number; +}; + +const COLOR_SWATCHES: Array<{ pattern: string; color: string }> = [ + { pattern: 'preto', color: '#171717' }, + { pattern: 'branco', color: '#f8fafc' }, + { pattern: 'bege', color: '#d7bf9a' }, + { pattern: 'café', color: '#79553d' }, + { pattern: 'cafe', color: '#79553d' }, + { pattern: 'perola', color: '#e7dfcf' }, + { pattern: 'pérola', color: '#e7dfcf' }, + { pattern: 'marinho', color: '#172554' }, + { pattern: 'bordo', color: '#6b1226' }, + { pattern: 'bordô', color: '#6b1226' }, + { pattern: 'verde', color: '#166534' }, + { pattern: 'rosa', color: '#f0a6bf' }, + { pattern: 'cinza', color: '#8f8f8f' }, + { pattern: 'vermelho', color: '#b91c1c' }, + { pattern: 'grafite', color: '#3f3f46' }, + { pattern: 'azul', color: '#2563eb' } +]; + +const getRangeDays = (range: DateRange) => { + const start = new Date(range.start); + const end = new Date(range.end); + start.setHours(0, 0, 0, 0); + end.setHours(0, 0, 0, 0); + return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1); +}; + +const formatNumber = (value: number, maximumFractionDigits = 0) => ( + new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value) +); + +const formatCurrency = (value: number) => ( + new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value) +); + +const formatDays = (value: number | null) => { + if (value === null) return '-'; + if (value > 999) return '999+ dias'; + return `${formatNumber(value, value < 10 ? 1 : 0)} dias`; +}; + +const getSwatchColor = (label: string) => { + const normalizedLabel = label.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase(); + return COLOR_SWATCHES.find(item => normalizedLabel.includes(item.pattern.normalize('NFD').replace(/\p{Diacritic}/gu, '')))?.color || '#64748b'; +}; + +const buildBreakdown = (rows: VariantRow[], field: 'color' | 'size') => { + const totals = new Map(); + + rows.forEach(row => { + const label = row[field] || (field === 'color' ? 'Sem cor' : 'Sem tamanho'); + const current = totals.get(label) || { label, quantitySold: 0, revenue: 0, stock: 0, skuCount: 0 }; + totals.set(label, { + label, + quantitySold: current.quantitySold + row.quantitySold, + revenue: current.revenue + row.revenue, + stock: current.stock + row.stock, + skuCount: current.skuCount + 1 + }); + }); + + const values = [...totals.values()]; + if (field === 'size') { + return values.sort((a, b) => { + const [sortedA, sortedB] = sortProductSizes([a.label, b.label]); + if (sortedA === a.label && sortedB === b.label) return 0; + return sortedA === a.label ? -1 : 1; + }); + } + + return values.sort((a, b) => b.quantitySold - a.quantitySold); +}; + +const BreakdownPanel = ({ + title, + subtitle, + rows, + type +}: { + title: string; + subtitle: string; + rows: BreakdownRow[]; + type: 'color' | 'size'; +}) => { + const maxSold = Math.max(...rows.map(row => row.quantitySold), 0); + + return ( +
+
+
+

{title}

+

{subtitle}

+
+ {type === 'color' ? ( + + ) : ( + + )} +
+ + {rows.length === 0 ? ( +
+ Sem dados para este grupo. +
+ ) : ( +
+ {rows.map(row => { + const width = maxSold ? Math.max(4, (row.quantitySold / maxSold) * 100) : 0; + const swatchColor = type === 'color' ? getSwatchColor(row.label) : '#25c2ff'; + + return ( +
+
+ + + {type === 'size' && row.label !== 'Sem tamanho' ? `Tam. ${row.label}` : row.label} + +
+
+
+
+
+
{formatNumber(row.quantitySold)} un.
+
{row.skuCount} SKUs
+
+
+ ); + })} +
+ )} +
+ ); +}; + +const ProductGroupDetailsSkeleton = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+ {[0, 1, 2, 3].map(item => ( +
+ ))} +
+
+
+
+
+
+); + +const ProductGroupDetails = () => { + const { groupKey } = useParams<{ groupKey: string }>(); + const { dateRange, setDateRange } = useOutletContext<{ + dateRange: DateRange, + setDateRange: (range: DateRange) => void + }>(); + const [products, setProducts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const groupName = useMemo(() => { + if (!groupKey) return ''; + + try { + return decodeProductGroupKey(groupKey); + } catch { + return ''; + } + }, [groupKey]); + + useEffect(() => { + let isMounted = true; + + const loadProducts = async () => { + setIsLoading(true); + const data = await fetchProductAnalytics(dateRange); + + if (isMounted) { + setProducts(data); + setIsLoading(false); + } + }; + + void loadProducts(); + + return () => { + isMounted = false; + }; + }, [dateRange]); + + const groupRows = useMemo(() => { + const rangeDays = getRangeDays(dateRange); + const normalizedGroupName = normalizeProductText(groupName).toLowerCase(); + + return products + .map(product => { + const metadata = parseProductName(product.name); + const dailySales = product.quantitySold / rangeDays; + + return { + ...product, + color: metadata.color, + size: metadata.size, + baseName: metadata.baseName, + dailySales, + daysOfCover: dailySales > 0 ? product.stock / dailySales : null + }; + }) + .filter(product => normalizeProductText(product.baseName).toLowerCase() === normalizedGroupName) + .sort((a, b) => b.quantitySold - a.quantitySold); + }, [dateRange, groupName, products]); + + const totals = useMemo(() => { + const totalSold = groupRows.reduce((total, row) => total + row.quantitySold, 0); + const totalRevenue = groupRows.reduce((total, row) => total + row.revenue, 0); + const totalStock = groupRows.reduce((total, row) => total + row.stock, 0); + const dailySales = groupRows.reduce((total, row) => total + row.dailySales, 0); + const daysOfCover = dailySales > 0 ? totalStock / dailySales : null; + const colors = new Set(groupRows.map(row => row.color).filter(Boolean)); + const sizes = new Set(groupRows.map(row => row.size).filter(Boolean)); + + return { + totalSold, + totalRevenue, + totalStock, + dailySales, + daysOfCover, + colorCount: colors.size, + sizeCount: sizes.size + }; + }, [groupRows]); + + const colorBreakdown = useMemo(() => buildBreakdown(groupRows, 'color'), [groupRows]); + const sizeBreakdown = useMemo(() => buildBreakdown(groupRows, 'size'), [groupRows]); + const isRefreshing = isLoading && products.length > 0; + + if (isLoading && products.length === 0) { + return ; + } + + if (!groupName || groupRows.length === 0) { + return ( +
+

Grupo de produtos não encontrado.

+ + Voltar para produtos + +
+ ); + } + + return ( +
+
+
+ + +
+
+ +
+
+

+ Grupo · {formatNumber(groupRows.length)} SKUs · {formatNumber(totals.colorCount)} cores · {formatNumber(totals.sizeCount)} tamanhos +

+

{groupName}

+
+
+
+ + +
+ + + +
+
+
+
+
+

Unidades vendidas

+

{formatNumber(totals.totalSold)}

+
+ +
+
+ +
+
+
+

Receita total

+

{formatCurrency(totals.totalRevenue)}

+
+ +
+
+ +
+
+
+

Estoque

+

{formatNumber(totals.totalStock)}

+
+ +
+
+ +
+
+
+

Cobertura estimada

+

{formatDays(totals.daysOfCover)}

+
+ +
+
+
+ +
+ + +
+ +
+
+

Variações do grupo

+

SKUs, cores e tamanhos que formam este grupo.

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + {groupRows.map(row => ( + + + + + + + + + + + + ))} + +
ID ProdutoDescriçãoCorTamanhoVendidoEstoqueMédia diáriaReceitaAções
#{row.id} +
{row.name}
+
Preço Atual: {formatCurrency(row.lastPrice)}
+
+ + + {row.color || '-'} + + + + {row.size || '-'} + + +
+ + {formatNumber(row.quantitySold)} un. +
+
{formatNumber(row.stock)} un.{formatNumber(row.dailySales, 2)} un./dia{formatCurrency(row.revenue)} + + + Ver SKU + +
+
+
+
+
+ ); +}; + +export default ProductGroupDetails; diff --git a/src/pages/Products.tsx b/src/pages/Products.tsx index cd07950..a380676 100644 --- a/src/pages/Products.tsx +++ b/src/pages/Products.tsx @@ -6,6 +6,7 @@ import RefreshStatus from '../components/RefreshStatus'; import type { DateRange, ProductAnalyticsItem } from '../types'; import { exportToCSV, fetchProductAnalytics } from '../dataService'; import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges'; +import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing'; type StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales'; type StockStatusFilter = 'all' | StockRisk; @@ -13,12 +14,23 @@ type StockQuantityFilter = 'all' | 'zero' | 'positive' | 'low' | 'high'; type SalesFilter = 'all' | 'sold' | 'not_sold'; type CoverageFilter = 'all' | 'up_to_7' | 'up_to_14' | 'up_to_30' | 'over_30' | 'none'; type ProductSortOption = 'sold_desc' | 'sold_asc' | 'stock_priority' | 'revenue_desc' | 'revenue_asc' | 'stock_asc' | 'stock_desc' | 'coverage_asc' | 'coverage_desc' | 'name_asc'; +type ProductViewMode = 'sku' | 'group'; type ProductRow = ProductAnalyticsItem & { dailySales: number; daysOfCover: number | null; risk: StockRisk; riskLabel: string; + baseName: string; + color: string; + size: string; + productIds: string[]; + skuCount: number; + colors: string[]; + sizes: string[]; + topColor: string; + topSize: string; + groupKey: string; }; const riskStyles: Record = { @@ -176,6 +188,7 @@ const Products = () => { const [stockQuantityFilter, setStockQuantityFilter] = useState('all'); const [salesFilter, setSalesFilter] = useState('all'); const [coverageFilter, setCoverageFilter] = useState('all'); + const [viewMode, setViewMode] = useState('sku'); const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false); const filterMenuRef = useRef(null); const [productAnalytics, setProductAnalytics] = useState([]); @@ -230,26 +243,100 @@ const Products = () => { const productsData = useMemo(() => { const days = getRangeDays(dateRange); const normalizedSearch = searchTerm.trim().toLowerCase(); - const products = productAnalytics.map(product => { + const skuRows = productAnalytics.map(product => { const dailySales = product.quantitySold / days; const risk = classifyStockRisk(product.stock, dailySales); const style = riskStyles[risk]; + const metadata = parseProductName(product.name); return { ...product, dailySales, daysOfCover: dailySales > 0 ? product.stock / dailySales : null, risk, - riskLabel: style.label + riskLabel: style.label, + baseName: metadata.baseName, + color: metadata.color, + size: metadata.size, + productIds: [product.id], + skuCount: 1, + colors: metadata.color ? [metadata.color] : [], + sizes: metadata.size ? [metadata.size] : [], + topColor: metadata.color || '-', + topSize: metadata.size || '-', + groupKey: encodeProductGroupKey(metadata.baseName) }; }); + const groups = new Map(); + + skuRows.forEach(product => { + const key = product.baseName.toLowerCase(); + const group = groups.get(key) || []; + group.push(product); + groups.set(key, group); + }); + + const groupedRows = Array.from(groups.values()).map(group => { + const first = group[0]; + const quantitySold = group.reduce((total, product) => total + product.quantitySold, 0); + const revenue = group.reduce((total, product) => total + product.revenue, 0); + const stock = group.reduce((total, product) => total + product.stock, 0); + const orderLineCount = group.reduce((total, product) => total + product.orderLineCount, 0); + const dailySales = quantitySold / days; + const daysOfCover = dailySales > 0 ? stock / dailySales : null; + const risk = classifyStockRisk(stock, dailySales); + const style = riskStyles[risk]; + const colorTotals = new Map(); + const sizeTotals = new Map(); + + group.forEach(product => { + if (product.color) { + colorTotals.set(product.color, (colorTotals.get(product.color) || 0) + product.quantitySold); + } + if (product.size) { + sizeTotals.set(product.size, (sizeTotals.get(product.size) || 0) + product.quantitySold); + } + }); + + const topColor = [...colorTotals.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || '-'; + const topSize = [...sizeTotals.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || '-'; + const colors = [...colorTotals.keys()].sort((a, b) => a.localeCompare(b, 'pt-BR')); + const sizes = sortProductSizes([...sizeTotals.keys()]); + + return { + ...first, + id: first.groupKey, + name: first.baseName, + quantitySold, + revenue, + orderLineCount, + stock, + dailySales, + daysOfCover, + risk, + riskLabel: style.label, + productIds: group.map(product => product.id), + skuCount: group.length, + colors, + sizes, + topColor, + topSize, + lastPrice: quantitySold > 0 ? revenue / quantitySold : first.lastPrice + }; + }); + + const activeRows = viewMode === 'group' ? groupedRows : skuRows; + const filteredProducts = normalizedSearch - ? products.filter(product => + ? activeRows.filter(product => product.name.toLowerCase().includes(normalizedSearch) || - product.id.toLowerCase().includes(normalizedSearch) + product.id.toLowerCase().includes(normalizedSearch) || + product.productIds.some(id => id.toLowerCase().includes(normalizedSearch)) || + product.colors.some(color => color.toLowerCase().includes(normalizedSearch)) || + product.sizes.some(size => size.toLowerCase().includes(normalizedSearch)) ) - : products; + : activeRows; const detailedFilteredProducts = filteredProducts.filter(product => { const matchesStatus = stockStatusFilter === 'all' || product.risk === stockStatusFilter; @@ -294,7 +381,7 @@ const Products = () => { return b.quantitySold - a.quantitySold; } }); - }, [coverageFilter, dateRange, productAnalytics, salesFilter, searchTerm, sortBy, stockQuantityFilter, stockStatusFilter]); + }, [coverageFilter, dateRange, productAnalytics, salesFilter, searchTerm, sortBy, stockQuantityFilter, stockStatusFilter, viewMode]); const activeFilterCount = (sortBy === 'sold_desc' ? 0 : 1) + @@ -373,6 +460,29 @@ const Products = () => {
+
+ {[ + { key: 'sku' as const, label: 'SKU' }, + { key: 'group' as const, label: 'Grupo' } + ].map(view => ( + + ))} +
+
{