diff --git a/src/App.tsx b/src/App.tsx index 01194b9..a5d99a4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,6 +46,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/pages/Products.tsx b/src/pages/Products.tsx index f544071..5d43ddc 100644 --- a/src/pages/Products.tsx +++ b/src/pages/Products.tsx @@ -1,30 +1,147 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; -import { Search, Package, TrendingUp, Download } from 'lucide-react'; -import DateRangePicker from '../components/DateRangePicker'; +import { Download, Filter, Package, PackageCheck, Search, TrendingDown, TrendingUp, X } from 'lucide-react'; import PaginationControls from '../components/PaginationControls'; import RefreshStatus from '../components/RefreshStatus'; import type { DateRange, ProductAnalyticsItem } from '../types'; import { exportToCSV, fetchProductAnalytics } from '../dataService'; -import type { ProductSummary } from '../analytics/products'; +import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges'; + +type StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales'; +type StockStatusFilter = 'all' | StockRisk; +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' | 'revenue_desc' | 'revenue_asc' | 'stock_asc' | 'stock_desc' | 'coverage_asc' | 'coverage_desc' | 'name_asc'; + +type ProductRow = ProductAnalyticsItem & { + dailySales: number; + daysOfCover: number | null; + risk: StockRisk; + riskLabel: string; +}; + +const riskStyles: Record = { + rupture: { + label: 'Sem estoque', + className: 'border-fuchsia-400/35 bg-fuchsia-400/10 text-fuchsia-300', + dotClass: 'bg-fuchsia-400' + }, + critical: { + label: 'Até 7 dias', + className: 'border-red-400/35 bg-red-400/10 text-red-300', + dotClass: 'bg-red-400' + }, + attention: { + label: 'Até 14 dias', + className: 'border-amber-400/35 bg-amber-400/10 text-amber-300', + dotClass: 'bg-amber-400' + }, + monitor: { + label: 'Até 30 dias', + className: 'border-sky-400/35 bg-sky-400/10 text-sky-300', + dotClass: 'bg-sky-400' + }, + healthy: { + label: 'Acima de 30 dias', + className: 'border-emerald-400/35 bg-emerald-400/10 text-emerald-300', + dotClass: 'bg-emerald-400' + }, + no_sales: { + label: 'Sem venda', + className: 'border-dark-border bg-dark-input text-dark-muted', + dotClass: 'bg-dark-muted' + } +}; + +const stockStatusOptions: Array<{ value: StockStatusFilter; label: string }> = [ + { value: 'all', label: 'Todos' }, + { value: 'rupture', label: 'Sem estoque' }, + { value: 'critical', label: 'Até 7 dias' }, + { value: 'attention', label: 'Até 14 dias' }, + { value: 'monitor', label: 'Até 30 dias' }, + { value: 'healthy', label: 'Acima de 30 dias' }, + { value: 'no_sales', label: 'Sem venda' } +]; + +const dateFilterPresets = [ + { value: 'today', label: 'Hoje', getRange: () => rangeForDay(new Date()) }, + { value: 'yesterday', label: 'Ontem', getRange: () => rangeForPreviousDay() }, + { value: '7d', label: 'Últimos 7 dias', getRange: () => rangeForLastDays(7) }, + { value: '30d', label: 'Últimos 30 dias', getRange: () => rangeForLastDays(30) }, + { value: 'month', label: 'Este mês', getRange: () => { + const end = endOfLocalDay(new Date()); + return { start: startOfLocalDay(new Date(end.getFullYear(), end.getMonth(), 1)), end }; + } }, + { value: 'previous-month', label: 'Mês passado', getRange: () => { + const today = new Date(); + return { + start: startOfLocalDay(new Date(today.getFullYear(), today.getMonth() - 1, 1)), + end: endOfLocalDay(new Date(today.getFullYear(), today.getMonth(), 0)) + }; + } }, + { value: '90d', label: 'Últimos 90 dias', getRange: () => rangeForLastDays(90) }, + { value: 'year', label: 'Este ano', getRange: () => { + const end = endOfLocalDay(new Date()); + return { start: startOfLocalDay(new Date(end.getFullYear(), 0, 1)), end }; + } }, + { value: 'all', label: 'Todo o período', getRange: () => ({ + start: startOfLocalDay(new Date(2000, 0, 1)), + end: endOfLocalDay(new Date()) + }) } +]; + +const filterSelectClassName = "w-full h-9 bg-dark-input border border-dark-border text-dark-text text-sm rounded-lg px-3 focus:outline-none focus:border-brand-primary transition-colors cursor-pointer"; + +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 classifyStockRisk = (stock: number, dailySales: number): StockRisk => { + if (stock <= 0) return 'rupture'; + if (dailySales <= 0) return 'no_sales'; + + const daysOfCover = stock / dailySales; + if (daysOfCover <= 7) return 'critical'; + if (daysOfCover <= 14) return 'attention'; + if (daysOfCover <= 30) return 'monitor'; + return 'healthy'; +}; + +const formatNumber = (value: number, maximumFractionDigits = 0) => ( + new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).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 ProductTableSkeleton = () => ( - - {[0, 1, 2, 3, 4, 5].map(item => ( + + {[0, 1, 2, 3, 4, 5, 6, 7, 8].map(item => ( ))} {[0, 1, 2, 3, 4, 5, 6, 7].map(row => ( - + + + + @@ -41,14 +158,20 @@ const ProductTableSkeleton = () => ( const Products = () => { const { dateRange, setDateRange } = useOutletContext<{ - dateRange: DateRange, + dateRange: DateRange, setDateRange: (range: DateRange) => void }>(); const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState('sold_desc'); + const [stockStatusFilter, setStockStatusFilter] = useState('all'); + const [stockQuantityFilter, setStockQuantityFilter] = useState('all'); + const [salesFilter, setSalesFilter] = useState('all'); + const [coverageFilter, setCoverageFilter] = useState('all'); + const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false); + const filterMenuRef = useRef(null); const [productAnalytics, setProductAnalytics] = useState([]); const [isLoading, setIsLoading] = useState(true); - // Pagination state const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); @@ -72,16 +195,36 @@ const Products = () => { }; }, [dateRange]); - const productsData = useMemo(() => { + useEffect(() => { + if (!isFilterMenuOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if (!filterMenuRef.current?.contains(event.target as Node)) { + setIsFilterMenuOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isFilterMenuOpen]); + + const productsData = useMemo(() => { + const days = getRangeDays(dateRange); const normalizedSearch = searchTerm.trim().toLowerCase(); - const products: ProductSummary[] = productAnalytics.map(product => ({ - id: product.id, - name: product.name, - totalSold: product.quantitySold, - revenue: product.revenue, - lastPrice: product.lastPrice, - stock: product.stock - })); + const products = productAnalytics.map(product => { + const dailySales = product.quantitySold / days; + const risk = classifyStockRisk(product.stock, dailySales); + const style = riskStyles[risk]; + + return { + ...product, + dailySales, + daysOfCover: dailySales > 0 ? product.stock / dailySales : null, + risk, + riskLabel: style.label + }; + }); + const filteredProducts = normalizedSearch ? products.filter(product => product.name.toLowerCase().includes(normalizedSearch) || @@ -89,10 +232,104 @@ const Products = () => { ) : products; - return filteredProducts.sort((a, b) => b.totalSold - a.totalSold); - }, [productAnalytics, searchTerm]); + const detailedFilteredProducts = filteredProducts.filter(product => { + const matchesStatus = stockStatusFilter === 'all' || product.risk === stockStatusFilter; + const matchesStockQuantity = + stockQuantityFilter === 'all' || + (stockQuantityFilter === 'zero' && product.stock <= 0) || + (stockQuantityFilter === 'positive' && product.stock > 0) || + (stockQuantityFilter === 'low' && product.stock > 0 && product.stock <= 10) || + (stockQuantityFilter === 'high' && product.stock > 100); + const matchesSales = + salesFilter === 'all' || + (salesFilter === 'sold' && product.quantitySold > 0) || + (salesFilter === 'not_sold' && product.quantitySold === 0); + const matchesCoverage = + coverageFilter === 'all' || + (coverageFilter === 'none' && product.daysOfCover === null) || + (coverageFilter === 'up_to_7' && product.daysOfCover !== null && product.daysOfCover <= 7) || + (coverageFilter === 'up_to_14' && product.daysOfCover !== null && product.daysOfCover <= 14) || + (coverageFilter === 'up_to_30' && product.daysOfCover !== null && product.daysOfCover <= 30) || + (coverageFilter === 'over_30' && product.daysOfCover !== null && product.daysOfCover > 30); + + return matchesStatus && matchesStockQuantity && matchesSales && matchesCoverage; + }); + + return detailedFilteredProducts.sort((a, b) => { + switch (sortBy) { + case 'sold_asc': return a.quantitySold - b.quantitySold; + case 'revenue_desc': return b.revenue - a.revenue; + case 'revenue_asc': return a.revenue - b.revenue; + case 'stock_asc': return a.stock - b.stock; + case 'stock_desc': return b.stock - a.stock; + case 'coverage_asc': return (a.daysOfCover ?? Number.POSITIVE_INFINITY) - (b.daysOfCover ?? Number.POSITIVE_INFINITY); + case 'coverage_desc': return (b.daysOfCover ?? -1) - (a.daysOfCover ?? -1); + case 'name_asc': return a.name.localeCompare(b.name, 'pt-BR'); + case 'sold_desc': + default: + return b.quantitySold - a.quantitySold; + } + }); + }, [coverageFilter, dateRange, productAnalytics, salesFilter, searchTerm, sortBy, stockQuantityFilter, stockStatusFilter]); + + const activeFilterCount = + (sortBy === 'sold_desc' ? 0 : 1) + + (stockStatusFilter === 'all' ? 0 : 1) + + (stockQuantityFilter === 'all' ? 0 : 1) + + (salesFilter === 'all' ? 0 : 1) + + (coverageFilter === 'all' ? 0 : 1); + const hasActiveFilters = activeFilterCount > 0; + + const resetProductFilters = () => { + setSortBy('sold_desc'); + setStockStatusFilter('all'); + setStockQuantityFilter('all'); + setSalesFilter('all'); + setCoverageFilter('all'); + setCurrentPage(1); + }; + + const datePresetValue = useMemo(() => { + const currentStart = formatDateParam(dateRange.start); + const currentEnd = formatDateParam(dateRange.end); + const preset = dateFilterPresets.find(option => { + const range = option.getRange(); + return formatDateParam(range.start) === currentStart && formatDateParam(range.end) === currentEnd; + }); + + return preset?.value || 'custom'; + }, [dateRange]); + + const updateDatePreset = (value: string) => { + if (value === 'custom') return; + + const preset = dateFilterPresets.find(option => option.value === value); + if (!preset) return; + + setDateRange(preset.getRange()); + setCurrentPage(1); + }; + + const updateDateStart = (value: string) => { + const nextStart = parseLocalDateInput(value); + if (!nextStart) return; + + const start = startOfLocalDay(nextStart); + const end = dateRange.end < start ? endOfLocalDay(start) : dateRange.end; + setDateRange({ start, end }); + setCurrentPage(1); + }; + + const updateDateEnd = (value: string) => { + const nextEnd = parseLocalDateInput(value); + if (!nextEnd) return; + + const end = endOfLocalDay(nextEnd); + const start = dateRange.start > end ? startOfLocalDay(end) : dateRange.start; + setDateRange({ start, end }); + setCurrentPage(1); + }; - // Pagination logic const totalPages = Math.ceil(productsData.length / itemsPerPage); const safeCurrentPage = Math.min(currentPage, totalPages || 1); const startIndex = (safeCurrentPage - 1) * itemsPerPage; @@ -105,43 +342,219 @@ const Products = () => { return ( - + Produtos - Gestão de catálogo e performance de vendas por item. + Performance de vendas, saldo e cobertura por produto. - - - { - setDateRange(range); - setCurrentPage(1); - }} - /> - - + + + - { setSearchTerm(e.target.value); setCurrentPage(1); }} - className="w-full md:w-64 bg-dark-card border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors shadow-sm" + className="w-full bg-dark-card border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors shadow-sm" /> - + + + setIsFilterMenuOpen(open => !open)} + className={`flex w-full items-center justify-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-medium shadow-sm transition-colors sm:w-auto ${ + hasActiveFilters + ? 'cursor-pointer border-brand-primary bg-brand-primary/10 text-brand-primary' + : 'cursor-pointer border-dark-border bg-dark-card text-dark-text hover:border-brand-primary' + }`} + > + + Filtros + {hasActiveFilters && ( + + {activeFilterCount} + + )} + + + {isFilterMenuOpen && ( + + + Filtros + setIsFilterMenuOpen(false)} + className="cursor-pointer rounded-lg p-1 text-dark-muted transition-colors hover:bg-dark-input hover:text-dark-text" + aria-label="Fechar filtros" + > + + + + + + + Período + updateDatePreset(event.target.value)} + className={`${filterSelectClassName} mt-1`} + > + Personalizado + {dateFilterPresets.map(preset => ( + {preset.label} + ))} + + + + + Ordenação + { + setSortBy(event.target.value as ProductSortOption); + setCurrentPage(1); + }} + className={`${filterSelectClassName} mt-1`} + > + Mais vendidos + Menos vendidos + Maior receita + Menor receita + Menor estoque + Maior estoque + Menor cobertura + Maior cobertura + Nome A-Z + + + + + De + updateDateStart(event.target.value)} + className={`${filterSelectClassName} mt-1`} + /> + + + + Até + updateDateEnd(event.target.value)} + className={`${filterSelectClassName} mt-1`} + /> + + + + Status + { + setStockStatusFilter(event.target.value as StockStatusFilter); + setCurrentPage(1); + }} + className={`${filterSelectClassName} mt-1`} + > + {stockStatusOptions.map(option => ( + {option.label} + ))} + + + + + Estoque + { + setStockQuantityFilter(event.target.value as StockQuantityFilter); + setCurrentPage(1); + }} + className={`${filterSelectClassName} mt-1`} + > + Todos + Zerado + Com estoque + Baixo: 1 a 10 un. + Alto: acima de 100 un. + + + + + Vendas + { + setSalesFilter(event.target.value as SalesFilter); + setCurrentPage(1); + }} + className={`${filterSelectClassName} mt-1`} + > + Todos + Vendeu no período + Não vendeu no período + + + + + Cobertura + { + setCoverageFilter(event.target.value as CoverageFilter); + setCurrentPage(1); + }} + className={`${filterSelectClassName} mt-1`} + > + Todos + Até 7 dias + Até 14 dias + Até 30 dias + Acima de 30 dias + Sem venda para calcular + + + + + + + Limpar + + setIsFilterMenuOpen(false)} + className="cursor-pointer rounded-xl bg-brand-primary px-4 py-2 text-sm font-bold text-brand-contrast transition-opacity hover:opacity-90" + > + Aplicar + + + + )} + + { const exportData = productsData.map(product => ({ 'ID Produto': product.id, 'Descrição': product.name, + 'Status Estoque': product.riskLabel, 'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','), - 'Total Vendido (un.)': product.totalSold, + 'Total Vendido (un.)': product.quantitySold, 'Estoque': product.stock, + 'Média Diária': product.dailySales.toFixed(2).replace('.', ','), + 'Cobertura': product.daysOfCover === null ? '' : product.daysOfCover.toFixed(1).replace('.', ','), 'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',') })); exportToCSV(exportData, `produtos_${new Date().toISOString().split('T')[0]}.csv`); @@ -160,71 +573,95 @@ const Products = () => { {isLoading && productAnalytics.length === 0 ? ( ) : ( - - - - - - ID Produto - Descrição - Total Vendido - Estoque - Receita Gerada - Ações - - - - {paginatedData.map((product) => ( - - #{product.id} - - {product.name} - Preço Atual: {formatCurrency(product.lastPrice)} - - - - - {product.totalSold} un. - - - - - {product.stock} un. - - - {formatCurrency(product.revenue)} - - - - Ver Gráfico - - - - ))} - - - + + + + + + ID Produto + Descrição + Status + Total Vendido + Estoque + Média Diária + Cobertura + Receita Gerada + Ações + + + + {paginatedData.map((product) => { + const style = riskStyles[product.risk]; - { - setItemsPerPage(pageSize); - setCurrentPage(1); - }} - /> - + return ( + + #{product.id} + + {product.name} + Preço Atual: {formatCurrency(product.lastPrice)} + + + + + {style.label} + + + + + + {formatNumber(product.quantitySold)} un. + + + {formatNumber(product.stock)} un. + {formatNumber(product.dailySales, 2)} un./dia + + + + {formatDays(product.daysOfCover)} + + + {formatCurrency(product.revenue)} + + + + Ver Gráfico + + + + ); + })} + + + + + {!productsData.length && ( + + + Nenhum produto encontrado. + Ajuste a busca ou o período selecionado. + + )} + + { + setItemsPerPage(pageSize); + setCurrentPage(1); + }} + /> + )} );
Gestão de catálogo e performance de vendas por item.
Performance de vendas, saldo e cobertura por produto.
Nenhum produto encontrado.
Ajuste a busca ou o período selecionado.