From 897d4cbe66bba91f26b4f71e10c8788971d9449b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Thu, 16 Jul 2026 12:53:17 -0300 Subject: [PATCH] Add planning issues work queue --- src/App.tsx | 2 + src/components/Layout.tsx | 3 +- src/pages/PlanningIssues.tsx | 432 +++++++++++++++++++++++++++++++++++ 3 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 src/pages/PlanningIssues.tsx diff --git a/src/App.tsx b/src/App.tsx index 90e9c5b..a160230 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ const ProductDetails = React.lazy(() => import('./pages/ProductDetails')); const ProductGroupDetails = React.lazy(() => import('./pages/ProductGroupDetails')); const Replenishment = React.lazy(() => import('./pages/Replenishment')); const Cutting = React.lazy(() => import('./pages/Cutting')); +const PlanningIssues = React.lazy(() => import('./pages/PlanningIssues')); const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders')); const Supplies = React.lazy(() => import('./pages/Supplies')); const Clients = React.lazy(() => import('./pages/Clients')); @@ -54,6 +55,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 5ca84a8..ddde5d5 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, Tags, Boxes } from 'lucide-react'; +import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList } from 'lucide-react'; import type { DateRange, OrderData } from '../types'; import { isSuperAdmin, logout } from '../dataService'; import { rangeForLastDays } from '../dateRanges'; @@ -73,6 +73,7 @@ const Layout = () => { label: 'Operação', items: [ { name: 'Produtos', href: '/products', icon: Package }, + { name: 'Dados Pendentes', href: '/planning-issues', icon: ClipboardList }, { name: 'Cadastros', href: '/registrations', icon: Tags }, { name: 'Suprimentos', href: '/supplies', icon: Boxes }, ], diff --git a/src/pages/PlanningIssues.tsx b/src/pages/PlanningIssues.tsx new file mode 100644 index 0000000..1430b0a --- /dev/null +++ b/src/pages/PlanningIssues.tsx @@ -0,0 +1,432 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link, useOutletContext } from 'react-router-dom'; +import { AlertTriangle, CheckCircle2, Eye, PackageSearch, Pencil, Search } from 'lucide-react'; +import DateRangePicker from '../components/DateRangePicker'; +import PaginationControls from '../components/PaginationControls'; +import ProductTypeBadge from '../components/ProductTypeBadge'; +import RefreshStatus from '../components/RefreshStatus'; +import SkuPlanningModal from '../components/SkuPlanningModal'; +import { classifyCutFamily, CUT_FAMILY_RULES, type CutFamilyKey } from '../analytics/cutting'; +import { fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService'; +import { getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification'; +import { parseProductName } from '../productParsing'; +import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem } from '../types'; + +type IssueKey = 'review_type' | 'missing_cut_family' | 'missing_color' | 'missing_size' | 'missing_yield'; +type IssueFilter = 'all' | IssueKey; + +type PlanningIssueRow = ProductAnalyticsItem & { + productType: ProductTypeKey; + color: string; + size: string; + familyKey: CutFamilyKey | ''; + familyLabel: string; + issues: IssueKey[]; + priorityScore: number; + hasOverride: boolean; +}; + +const issueLabels: Record = { + review_type: 'Revisar tipo', + missing_cut_family: 'Sem família', + missing_color: 'Sem cor', + missing_size: 'Sem tamanho', + missing_yield: 'Sem rendimento' +}; + +const issueHelp: Record = { + review_type: 'O classificador não conseguiu identificar o tipo do SKU.', + missing_cut_family: 'SKU de vestuário não caiu em uma família de corte confiável.', + missing_color: 'SKU de vestuário não tem cor clara para planejamento.', + missing_size: 'SKU de vestuário não tem tamanho claro para planejamento.', + missing_yield: 'A família existe, mas ainda falta rendimento em unidades por rolo.' +}; + +const issueFilters: Array<{ value: IssueFilter; label: string }> = [ + { value: 'all', label: 'Todos' }, + { value: 'review_type', label: issueLabels.review_type }, + { value: 'missing_cut_family', label: issueLabels.missing_cut_family }, + { value: 'missing_color', label: issueLabels.missing_color }, + { value: 'missing_size', label: issueLabels.missing_size }, + { value: 'missing_yield', label: issueLabels.missing_yield } +]; + +const familyLabels = new Map([ + ...CUT_FAMILY_RULES.map(rule => [rule.key, `${rule.materialLabel} · ${rule.label}`] as const), + ['OUTROS', 'Sem regra'] +]); + +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 buildIssues = ( + product: ProductAnalyticsItem, + settings: CuttingSettings +): PlanningIssueRow => { + const override = settings.productOverrides[product.id]; + const metadata = parseProductName(product.name); + const productType = resolveProductType(product.name, override); + const familyKey = override?.familyKey || classifyCutFamily(metadata.baseName).key; + const color = override?.color || metadata.color; + const size = (override?.size || metadata.size).toUpperCase(); + const issues: IssueKey[] = []; + + if (productType === 'unknown') issues.push('review_type'); + + if (productType === 'finished_apparel') { + if (familyKey === 'OUTROS') issues.push('missing_cut_family'); + if (!color) issues.push('missing_color'); + if (!size) issues.push('missing_size'); + if (familyKey !== 'OUTROS' && !settings.familyYields[familyKey]) issues.push('missing_yield'); + } + + return { + ...product, + productType, + color, + size, + familyKey, + familyLabel: familyLabels.get(familyKey) || '-', + issues, + priorityScore: (product.quantitySold * 2) + (product.revenue / 100), + hasOverride: Boolean(override) + }; +}; + +const PlanningIssuesSkeleton = () => ( +
+
+
+ {[0, 1, 2, 3, 4, 5, 6].map(item =>
)} +
+
+
+ {[0, 1, 2, 3, 4, 5].map(row => ( +
+ {[0, 1, 2, 3, 4, 5, 6].map(item =>
)} +
+ ))} +
+
+); + +const PlanningIssues = () => { + const { dateRange, setDateRange } = useOutletContext<{ + dateRange: DateRange, + setDateRange: (range: DateRange) => void + }>(); + const [products, setProducts] = useState([]); + const [settings, setSettings] = useState({ familyYields: {}, productOverrides: {} }); + const [isLoading, setIsLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [issueFilter, setIssueFilter] = useState('all'); + const [editingProduct, setEditingProduct] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(20); + + useEffect(() => { + let isMounted = true; + + const loadData = async () => { + setIsLoading(true); + const [productData, planningSettings] = await Promise.all([ + fetchProductAnalytics(dateRange), + fetchCuttingSettings() + ]); + + if (isMounted) { + setProducts(productData); + setSettings(planningSettings); + setIsLoading(false); + } + }; + + void loadData(); + + return () => { + isMounted = false; + }; + }, [dateRange]); + + const rows = useMemo(() => { + const normalizedSearch = searchTerm.trim().toLowerCase(); + + return products + .map(product => buildIssues(product, settings)) + .filter(row => row.issues.length > 0) + .filter(row => issueFilter === 'all' || row.issues.includes(issueFilter)) + .filter(row => { + if (!normalizedSearch) return true; + const typeLabel = getProductTypeConfig(row.productType).label.toLowerCase(); + return ( + row.id.toLowerCase().includes(normalizedSearch) || + row.name.toLowerCase().includes(normalizedSearch) || + row.color.toLowerCase().includes(normalizedSearch) || + row.size.toLowerCase().includes(normalizedSearch) || + row.familyLabel.toLowerCase().includes(normalizedSearch) || + typeLabel.includes(normalizedSearch) + ); + }) + .sort((a, b) => { + if (b.issues.length !== a.issues.length) return b.issues.length - a.issues.length; + return b.priorityScore - a.priorityScore; + }); + }, [issueFilter, products, searchTerm, settings]); + + const issueCounts = useMemo(() => { + const counts = new Map(); + products + .map(product => buildIssues(product, settings)) + .forEach(row => row.issues.forEach(issue => counts.set(issue, (counts.get(issue) || 0) + 1))); + return counts; + }, [products, settings]); + + const saveProductOverride = async (productId: string, override: CutProductOverride | null) => { + const productOverrides = { ...settings.productOverrides }; + if (override) { + productOverrides[productId] = override; + } else { + delete productOverrides[productId]; + } + + const nextSettings = { ...settings, productOverrides }; + setIsSaving(true); + try { + const savedSettings = await saveCuttingSettings(nextSettings); + setSettings(savedSettings); + setEditingProduct(null); + } finally { + setIsSaving(false); + } + }; + + const totalPages = Math.ceil(rows.length / itemsPerPage); + const safeCurrentPage = Math.min(currentPage, totalPages || 1); + const startIndex = (safeCurrentPage - 1) * itemsPerPage; + const paginatedRows = rows.slice(startIndex, startIndex + itemsPerPage); + const isRefreshing = isLoading && products.length > 0; + + return ( +
+
+
+

Dados Pendentes

+

+ Fila de SKUs com informações faltando para corte e planejamento. +

+
+ { + setDateRange(range); + setCurrentPage(1); + }} + /> +
+ + + +
+ {issueFilters.filter(option => option.value !== 'all').map(option => { + const count = issueCounts.get(option.value as IssueKey) || 0; + return ( + + ); + })} +
+ +
+
+
+
+ + { + setSearchTerm(event.target.value); + setCurrentPage(1); + }} + placeholder="Buscar SKU, produto, cor, tipo..." + className="h-10 w-full rounded-xl border border-dark-border bg-dark-input pl-10 pr-3 text-sm font-semibold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary" + /> +
+ +
+
+ {formatNumber(rows.length)} SKUs pendentes +
+
+ + {isLoading && !products.length ? ( + + ) : rows.length ? ( + <> +
+ + + + + + + + + + + + + + + + + + + + + + + + + {paginatedRows.map(row => ( + + + + + + + + + + + ))} + +
SKUProdutoTipoPendênciasFamíliaVendidoReceitaAções
#{row.id} +
{row.name}
+
+ Cor: {row.color || '-'} + · + Tam.: {row.size || '-'} + {row.hasOverride && manual} +
+
+ + +
+ {row.issues.map(issue => ( + + {issueLabels[issue]} + + ))} +
+
{row.familyLabel}{formatNumber(row.quantitySold)} un.{formatCurrency(row.revenue)} +
+ + + + +
+
+
+ { + setItemsPerPage(size); + setCurrentPage(1); + }} + /> + + ) : ( +
+ {products.length ? ( + <> + +

Nenhum dado pendente neste filtro.

+

Ajuste o filtro ou o período para revisar outros SKUs.

+ + ) : ( + <> + +

Sem produtos no período.

+

Altere o intervalo de datas para carregar a fila.

+ + )} +
+ )} +
+ + {editingProduct && ( + setEditingProduct(null)} + onSave={(override) => saveProductOverride(editingProduct.id, override)} + /> + )} +
+ ); +}; + +export default PlanningIssues;