import { useEffect, useMemo, useRef, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; 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 { 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-zinc-500/30 bg-zinc-500/10 text-zinc-300', dotClass: 'bg-zinc-400' }, critical: { label: '0-7 dias', className: 'border-red-400/35 bg-red-400/10 text-red-300', dotClass: 'bg-red-400' }, attention: { label: '8-14 dias', className: 'border-amber-400/35 bg-amber-400/10 text-amber-300', dotClass: 'bg-amber-400' }, monitor: { label: '15-30 dias', className: 'border-sky-400/35 bg-sky-400/10 text-sky-300', dotClass: 'bg-sky-400' }, healthy: { label: '>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: '0-7 dias' }, { value: 'attention', label: '8-14 dias' }, { value: 'monitor', label: '15-30 dias' }, { value: 'healthy', label: '>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, 6, 7, 8].map(item => (
))}
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
))}
); const Products = () => { const { dateRange, setDateRange } = useOutletContext<{ 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); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); useEffect(() => { let isMounted = true; const loadProducts = async () => { setIsLoading(true); const products = await fetchProductAnalytics(dateRange); if (isMounted) { setProductAnalytics(products); setIsLoading(false); } }; void loadProducts(); return () => { isMounted = false; }; }, [dateRange]); 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 = 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) || product.id.toLowerCase().includes(normalizedSearch) ) : products; 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); }; const totalPages = Math.ceil(productsData.length / itemsPerPage); const safeCurrentPage = Math.min(currentPage, totalPages || 1); const startIndex = (safeCurrentPage - 1) * itemsPerPage; const paginatedData = productsData.slice(startIndex, startIndex + itemsPerPage); const isRefreshing = isLoading && productAnalytics.length > 0; const formatCurrency = (value: number) => { return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); }; return (

Produtos

Performance de vendas, saldo e cobertura por produto.

{ setSearchTerm(e.target.value); setCurrentPage(1); }} 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

)}
{isLoading && productAnalytics.length === 0 ? ( ) : (
{paginatedData.map((product) => { const style = riskStyles[product.risk]; return ( ); })}
ID Produto Descrição Faixa de cobertura Total Vendido Estoque Média Diária Cobertura estimada Receita Gerada Ações
#{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); }} />
)}
); }; export default Products;