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" />
- + +
+ + + {isFilterMenuOpen && ( +
+
+

Filtros

+ +
+ +
+ + + + + + + + + + + + + + + +
+ +
+ + +
+
+ )} +
+