diff --git a/src/App.tsx b/src/App.tsx index a5d99a4..db2e85f 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 Replenishment = React.lazy(() => import('./pages/Replenishment')); const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders')); const Clients = React.lazy(() => import('./pages/Clients')); const ClientDetails = React.lazy(() => import('./pages/ClientDetails')); @@ -46,6 +47,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 3e966aa..39071f2 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { Outlet, Link, useLocation } from 'react-router-dom'; -import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, ClipboardList } from 'lucide-react'; +import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, ClipboardList, PackagePlus } from 'lucide-react'; import type { DateRange, OrderData } from '../types'; import { isSuperAdmin, logout } from '../dataService'; import { rangeForLastDays } from '../dateRanges'; @@ -62,6 +62,7 @@ const Layout = () => { const appNavigation = [ { name: 'Dashboard', href: '/graph', icon: LayoutDashboard }, { name: 'Produtos', href: '/products', icon: Package }, + { name: 'Reposição', href: '/replenishment', icon: PackagePlus }, { name: 'Ordens de Produção', href: '/production-orders', icon: ClipboardList }, { name: 'Clientes', href: '/clients', icon: Users }, { name: 'RFV', href: '/rfm', icon: Grid3X3 }, diff --git a/src/pages/Replenishment.tsx b/src/pages/Replenishment.tsx new file mode 100644 index 0000000..76e8c61 --- /dev/null +++ b/src/pages/Replenishment.tsx @@ -0,0 +1,464 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link, useOutletContext } from 'react-router-dom'; +import { AlertTriangle, CheckCircle2, Download, Package, Search, TrendingUp } from 'lucide-react'; +import DateRangePicker from '../components/DateRangePicker'; +import PaginationControls from '../components/PaginationControls'; +import RefreshStatus from '../components/RefreshStatus'; +import type { DateRange, ProductAnalyticsItem } from '../types'; +import { exportToCSV, fetchProductAnalytics } from '../dataService'; + +type ReplenishmentStatus = 'need' | 'covered' | 'no_sales' | 'no_stock'; +type ReplenishmentFilter = 'all' | ReplenishmentStatus; +type ReplenishmentSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'coverage_asc' | 'sold_desc' | 'name_asc'; + +type ReplenishmentRow = ProductAnalyticsItem & { + dailySales: number; + projectedDemand: number; + suggestedQuantity: number; + daysOfCover: number | null; + status: ReplenishmentStatus; + statusLabel: string; +}; + +const horizonOptions = [7, 15, 30, 60]; + +const statusStyles: Record = { + need: { + label: 'Repor', + className: 'border-red-400/35 bg-red-400/10 text-red-300', + dotClass: 'bg-red-400' + }, + no_stock: { + label: 'Sem estoque', + className: 'border-zinc-500/30 bg-zinc-500/10 text-zinc-300', + dotClass: 'bg-zinc-400' + }, + covered: { + label: 'Coberto', + 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 filterOptions: Array<{ value: ReplenishmentFilter; label: string }> = [ + { value: 'need', label: 'Com necessidade' }, + { value: 'all', label: 'Todos' }, + { value: 'no_stock', label: 'Sem estoque' }, + { value: 'covered', label: 'Cobertos' }, + { value: 'no_sales', label: 'Sem venda' } +]; + +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 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 ReplenishmentSkeleton = () => ( +
+
+
+ {[0, 1, 2, 3, 4, 5, 6, 7].map(item => ( +
+ ))} +
+
+
+ {[0, 1, 2, 3, 4, 5, 6, 7].map(row => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+); + +const Replenishment = () => { + const { dateRange, setDateRange } = useOutletContext<{ + dateRange: DateRange, + setDateRange: (range: DateRange) => void + }>(); + const [products, setProducts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [horizonDays, setHorizonDays] = useState(30); + const [statusFilter, setStatusFilter] = useState('need'); + const [sortBy, setSortBy] = useState('need_desc'); + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(10); + + 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 allRows = useMemo(() => { + const rangeDays = getRangeDays(dateRange); + + return products.map(product => { + const dailySales = product.quantitySold / rangeDays; + const projectedDemand = dailySales * horizonDays; + const rawNeed = projectedDemand - product.stock; + const suggestedQuantity = Math.max(0, Math.ceil(rawNeed)); + const daysOfCover = dailySales > 0 ? product.stock / dailySales : null; + const status: ReplenishmentStatus = dailySales <= 0 + ? 'no_sales' + : product.stock <= 0 + ? 'no_stock' + : suggestedQuantity > 0 + ? 'need' + : 'covered'; + + return { + ...product, + dailySales, + projectedDemand, + suggestedQuantity, + daysOfCover, + status, + statusLabel: statusStyles[status].label + }; + }); + }, [dateRange, horizonDays, products]); + + const filteredRows = useMemo(() => { + const normalizedSearch = searchTerm.trim().toLowerCase(); + const searchedRows = normalizedSearch + ? allRows.filter(row => + row.name.toLowerCase().includes(normalizedSearch) || + row.id.toLowerCase().includes(normalizedSearch) + ) + : allRows; + + const statusRows = statusFilter === 'all' + ? searchedRows + : searchedRows.filter(row => row.status === statusFilter); + + return [...statusRows].sort((a, b) => { + switch (sortBy) { + case 'need_asc': return a.suggestedQuantity - b.suggestedQuantity; + case 'demand_desc': return b.projectedDemand - a.projectedDemand; + case 'stock_asc': return a.stock - b.stock; + case 'coverage_asc': return (a.daysOfCover ?? Number.POSITIVE_INFINITY) - (b.daysOfCover ?? Number.POSITIVE_INFINITY); + case 'sold_desc': return b.quantitySold - a.quantitySold; + case 'name_asc': return a.name.localeCompare(b.name, 'pt-BR'); + case 'need_desc': + default: + return b.suggestedQuantity - a.suggestedQuantity; + } + }); + }, [allRows, searchTerm, sortBy, statusFilter]); + + const totalPages = Math.ceil(filteredRows.length / itemsPerPage); + const safeCurrentPage = Math.min(currentPage, totalPages || 1); + const startIndex = (safeCurrentPage - 1) * itemsPerPage; + const paginatedRows = filteredRows.slice(startIndex, startIndex + itemsPerPage); + const isRefreshing = isLoading && products.length > 0; + + const needRows = allRows.filter(row => row.suggestedQuantity > 0); + const totalSuggestedQuantity = needRows.reduce((total, row) => total + row.suggestedQuantity, 0); + const projectedDemand = allRows.reduce((total, row) => total + row.projectedDemand, 0); + const totalStock = allRows.reduce((total, row) => total + row.stock, 0); + + return ( +
+
+
+

Necessidade de Reposição

+

+ Demanda projetada pelo ritmo de venda atual comparada com o estoque disponível. +

+
+ +
+ { + setDateRange(range); + setCurrentPage(1); + }} + /> + + +
+
+ + + +
+
+
+
+

Produtos a repor

+

{formatNumber(needRows.length)}

+

Com estoque abaixo da demanda projetada

+
+
+ +
+
+
+ +
+
+
+

Sugestão total

+

{formatNumber(totalSuggestedQuantity)}

+

Unidades para cobrir {horizonDays} dias

+
+
+ +
+
+
+ +
+
+
+

Demanda projetada

+

{formatNumber(projectedDemand)}

+

Pelo ritmo do período selecionado

+
+
+ +
+
+
+ +
+
+
+

Produtos cobertos

+

{formatNumber(allRows.filter(row => row.status === 'covered').length)}

+

{formatNumber(totalStock)} unidades em estoque

+
+
+ +
+
+
+
+ +
+
+ + { + setSearchTerm(event.target.value); + setCurrentPage(1); + }} + className="w-full bg-dark-input 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" + /> +
+ + + + + + +
+ + {isLoading && products.length === 0 ? ( + + ) : ( +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + {paginatedRows.map(row => { + const style = statusStyles[row.status]; + + return ( + + + + + + + + + + + ); + })} + +
ID ProdutoDescriçãoStatusDemanda proj.EstoqueSugestão reposiçãoCoberturaAções
#{row.id} +
{row.name}
+
+ Média: {formatNumber(row.dailySales, 2)} un./dia · Vendido: {formatNumber(row.quantitySold)} un. +
+
+ + + {style.label} + + {formatNumber(row.projectedDemand, 1)} un.{formatNumber(row.stock)} un. + 0 ? 'font-bold text-red-300' : 'font-bold text-emerald-300'}> + {formatNumber(row.suggestedQuantity)} un. + + {formatDays(row.daysOfCover)} + + Ver produto + +
+
+ + {!filteredRows.length && ( +
+ +

Nenhum produto encontrado.

+

Ajuste os filtros, a busca ou o período selecionado.

+
+ )} + + { + setItemsPerPage(pageSize); + setCurrentPage(1); + }} + /> +
+ )} +
+ ); +}; + +export default Replenishment;