diff --git a/backend/routes/analyticsRoutes.js b/backend/routes/analyticsRoutes.js index 3c0f3cd..5cbfe47 100644 --- a/backend/routes/analyticsRoutes.js +++ b/backend/routes/analyticsRoutes.js @@ -11,7 +11,6 @@ const { getRfmAnalytics } = require('../services/analyticsService'); const { getProductComposition, listProductCompositions } = require('../services/productionOrderService'); -const { getDataHealthSummary } = require('../services/dataHealthService'); const router = express.Router(); @@ -79,15 +78,6 @@ router.get('/analytics/product-compositions', verifyToken, async (req, res) => { } }); -router.get('/analytics/data-health', verifyToken, async (req, res) => { - try { - res.json(await getDataHealthSummary()); - } catch (error) { - console.error('Error fetching data health:', error); - res.status(500).json({ error: 'Internal Server Error' }); - } -}); - router.get('/analytics/clients', verifyToken, async (req, res) => { try { res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query))); diff --git a/backend/services/dataHealthService.js b/backend/services/dataHealthService.js deleted file mode 100644 index 07dfbd5..0000000 --- a/backend/services/dataHealthService.js +++ /dev/null @@ -1,85 +0,0 @@ -const { pool } = require('../db'); - -const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); -const normalizeSku = (value) => normalizeText(value).toUpperCase(); -const isRawOrService = (value) => /\b(?:MALHA|RIBANA|FIO|TECIDO|SERVICO|SERVIÇO|TINTURARIA|TECELAGEM|FRETE)\b/i.test(value); - -const getDataHealthSummary = async () => { - const [productsResult, compositionsResult, componentsResult, stockResult] = await Promise.all([ - pool.query(` - SELECT produto_id AS id, MAX(NULLIF(produto_descricao, '')) AS name FROM orders GROUP BY produto_id - UNION - SELECT produto_id AS id, MAX(NULLIF(nome, '')) AS name FROM stock GROUP BY produto_id; - `), - pool.query(` - SELECT id, finished_tiny_product_id, finished_product_sku, finished_product_description, finished_product_unit - FROM product_compositions WHERE source = 'tiny_olist_v3'; - `), - pool.query(` - SELECT product_composition_id, component_tiny_id, component_sku, component_name, quantity_per_unit, unit - FROM product_composition_components; - `), - pool.query(`SELECT produto_id, nome, COALESCE(saldo, 0)::numeric AS saldo FROM stock;`) - ]); - - const products = Array.from(new Map(productsResult.rows.map(row => [normalizeSku(row.id), row])).values()); - const compositions = compositionsResult.rows; - const components = componentsResult.rows; - const stock = stockResult.rows; - const stockKeys = new Set(stock.flatMap(item => [normalizeSku(item.produto_id), normalizeSku(item.nome)]).filter(Boolean)); - const compositionKeys = new Set(compositions.flatMap(item => [normalizeSku(item.finished_tiny_product_id), normalizeSku(item.finished_product_sku)]).filter(Boolean)); - const productKeys = new Set(products.map(item => normalizeSku(item.id)).filter(Boolean)); - const linkedComponentKeys = new Set([...stockKeys, ...productKeys]); - const materialKeys = new Set(components.map(item => normalizeSku(item.component_tiny_id) || normalizeSku(item.component_sku) || normalizeSku(item.component_name)).filter(Boolean)); - const materialStockKeys = new Set([...materialKeys].filter(key => stockKeys.has(key))); - const rowsByComposition = new Map(compositions.map(item => [Number(item.id), item])); - const issues = []; - const addIssue = (type, severity, title, detail, productSku = '', component = '') => { - issues.push({ type, severity, title, detail, productSku, component }); - }; - - compositions.filter(row => !normalizeText(row.finished_product_sku)).forEach(row => { - addIssue('missing_product_sku', 'critical', 'Produto sem SKU', normalizeText(row.finished_product_description) || 'Composição sem descrição', '', ''); - }); - components.filter(row => { - const key = normalizeSku(row.component_tiny_id) || normalizeSku(row.component_sku) || normalizeSku(row.component_name); - return !key || !linkedComponentKeys.has(key); - }).forEach(row => { - const parent = rowsByComposition.get(Number(row.product_composition_id)); - addIssue('component_without_stock_link', 'attention', 'Componente sem vínculo de estoque', normalizeText(row.component_name) || 'Componente sem nome', normalizeText(parent?.finished_product_sku), normalizeText(row.component_sku || row.component_tiny_id)); - }); - components.filter(row => { - const quantity = Number(row.quantity_per_unit || 0); - return !Number.isFinite(quantity) || quantity <= 0 || quantity > 100 || !normalizeText(row.unit); - }).forEach(row => { - const parent = rowsByComposition.get(Number(row.product_composition_id)); - addIssue('suspicious_quantity', 'attention', 'Quantidade suspeita', `${normalizeText(row.component_name)} · ${row.quantity_per_unit || 0} ${normalizeText(row.unit) || '(sem unidade)'}`, normalizeText(parent?.finished_product_sku), normalizeText(row.component_sku || row.component_tiny_id)); - }); - compositions.filter(row => isRawOrService(row.finished_product_description) || normalizeText(row.finished_product_unit).toLowerCase() !== 'un').forEach(row => { - addIssue('raw_or_service_structure', 'info', 'Estrutura de matéria-prima/serviço', normalizeText(row.finished_product_description), normalizeText(row.finished_product_sku), ''); - }); - products.filter(row => !compositionKeys.has(normalizeSku(row.id))).forEach(row => { - addIssue('product_without_composition', 'attention', 'Produto sem composição', normalizeText(row.name) || normalizeText(row.id), normalizeText(row.id), ''); - }); - - const productsWithComposition = products.filter(row => compositionKeys.has(normalizeSku(row.id))).length; - const productsWithStockLink = products.filter(row => stockKeys.has(normalizeSku(row.id))).length; - - return { - totals: { - products: productKeys.size, - productsWithComposition, - productsWithStockLink, - materials: materialKeys.size, - materialsWithStock: materialStockKeys.size, - compositions: compositions.length, - components: components.length - }, - issues: issues.sort((a, b) => { - const severityOrder = { critical: 0, attention: 1, info: 2 }; - return severityOrder[a.severity] - severityOrder[b.severity] || a.title.localeCompare(b.title); - }) - }; -}; - -module.exports = { getDataHealthSummary }; diff --git a/src/App.tsx b/src/App.tsx index fb55b89..a160230 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,6 @@ 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 DataHealth = React.lazy(() => import('./pages/DataHealth')); const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders')); const Supplies = React.lazy(() => import('./pages/Supplies')); const Clients = React.lazy(() => import('./pages/Clients')); @@ -57,7 +56,6 @@ function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 9efee90..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, ClipboardList, Database } 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'; @@ -74,7 +74,6 @@ const Layout = () => { items: [ { name: 'Produtos', href: '/products', icon: Package }, { name: 'Dados Pendentes', href: '/planning-issues', icon: ClipboardList }, - { name: 'Saúde dos Dados', href: '/data-health', icon: Database }, { name: 'Cadastros', href: '/registrations', icon: Tags }, { name: 'Suprimentos', href: '/supplies', icon: Boxes }, ], diff --git a/src/dataService.ts b/src/dataService.ts index b867004..692d61e 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DataHealthSummary, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -579,17 +579,6 @@ export const fetchProductCompositions = async (): Promise } }; -export const fetchDataHealth = async (): Promise => { - try { - const response = await authFetch('/analytics/data-health'); - if (!response.ok) return null; - return await response.json() as DataHealthSummary; - } catch (error) { - console.error('Fetch data health failed', error); - return null; - } -}; - export const exportProductCompositions = async (): Promise => { const response = await authFetch('/analytics/product-compositions'); const data = await response.json().catch(() => null) as { compositions?: ProductComposition[]; error?: string } | null; diff --git a/src/pages/DataHealth.tsx b/src/pages/DataHealth.tsx deleted file mode 100644 index b7d1e99..0000000 --- a/src/pages/DataHealth.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { AlertTriangle, CheckCircle2, Database, PackageSearch, RefreshCw } from 'lucide-react'; -import PaginationControls from '../components/PaginationControls'; -import { fetchDataHealth } from '../dataService'; -import type { DataHealthIssue, DataHealthSummary } from '../types'; - -const emptySummary: DataHealthSummary = { - totals: { products: 0, productsWithComposition: 0, productsWithStockLink: 0, materials: 0, materialsWithStock: 0, compositions: 0, components: 0 }, - issues: [], -}; - -const formatNumber = (value: number) => new Intl.NumberFormat('pt-BR').format(value); -const percent = (part: number, total: number) => total ? Math.round((part / total) * 100) : 0; -const severityStyle: Record = { - critical: 'border-red-400/30 bg-red-400/10 text-red-300', - attention: 'border-amber-400/30 bg-amber-400/10 text-amber-300', - info: 'border-sky-400/30 bg-sky-400/10 text-sky-300', -}; - -const DataHealth = () => { - const [summary, setSummary] = useState(emptySummary); - const [isLoading, setIsLoading] = useState(true); - const [filter, setFilter] = useState<'all' | DataHealthIssue['type']>('all'); - const [search, setSearch] = useState(''); - const [currentPage, setCurrentPage] = useState(1); - const [itemsPerPage, setItemsPerPage] = useState(20); - - const load = async () => { - setIsLoading(true); - const next = await fetchDataHealth(); - if (next) setSummary(next); - setIsLoading(false); - }; - - useEffect(() => { - const timer = window.setTimeout(() => { void load(); }, 0); - return () => window.clearTimeout(timer); - }, []); - - const filteredIssues = useMemo(() => { - const query = search.trim().toLowerCase(); - return summary.issues.filter(issue => ( - (filter === 'all' || issue.type === filter) && - (!query || `${issue.title} ${issue.detail} ${issue.productSku} ${issue.component}`.toLowerCase().includes(query)) - )); - }, [filter, search, summary.issues]); - const totalPages = Math.ceil(filteredIssues.length / itemsPerPage); - const safePage = Math.min(currentPage, totalPages || 1); - const startIndex = (safePage - 1) * itemsPerPage; - const visibleIssues = filteredIssues.slice(startIndex, startIndex + itemsPerPage); - const count = (type: DataHealthIssue['type']) => summary.issues.filter(issue => issue.type === type).length; - const metrics = [ - { label: 'Produtos com composição', value: percent(summary.totals.productsWithComposition, summary.totals.products), detail: `${formatNumber(summary.totals.productsWithComposition)} de ${formatNumber(summary.totals.products)}` }, - { label: 'Produtos com estoque', value: percent(summary.totals.productsWithStockLink, summary.totals.products), detail: `${formatNumber(summary.totals.productsWithStockLink)} de ${formatNumber(summary.totals.products)}` }, - { label: 'Materiais com estoque', value: percent(summary.totals.materialsWithStock, summary.totals.materials), detail: `${formatNumber(summary.totals.materialsWithStock)} de ${formatNumber(summary.totals.materials)}` }, - ]; - - return ( -
-
-
-

Saúde dos Dados

-

Confira se composição, vínculo de estoque e consumo estão prontos para orientar compra e corte.

-
- -
- -
- {metrics.map(metric => ( -
-

{metric.label}

-

{metric.value}%

{metric.detail}

-
-
- ))} -
- -
- {[ - ['missing_product_sku', 'Sem SKU'], ['component_without_stock_link', 'Sem vínculo'], ['suspicious_quantity', 'Qtd. suspeita'], ['raw_or_service_structure', 'Matéria-prima/serviço'], ['product_without_composition', 'Sem composição'], - ].map(([type, label]) => ( - - ))} -
- -
-
- { setSearch(event.target.value); setCurrentPage(1); }} placeholder="Buscar produto, SKU ou componente..." className="h-10 flex-1 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" /> - -
- {isLoading ?
Verificando dados…
: !visibleIssues.length ?
Nenhuma pendência neste filtro.
: ( - <>
{visibleIssues.map((issue, index) =>

{issue.title}

{issue.severity === 'critical' ? 'Crítico' : issue.severity === 'attention' ? 'Revisar' : 'Informativo'}

{issue.detail}

{(issue.productSku || issue.component) &&

{[issue.productSku && `SKU ${issue.productSku}`, issue.component && `Comp. ${issue.component}`].filter(Boolean).join(' · ')}

}
)}
- { setItemsPerPage(size); setCurrentPage(1); }} className="border-t border-dark-border px-4 py-3" /> - )} -
-
- ); -}; - -export default DataHealth; diff --git a/src/types.ts b/src/types.ts index 62a1139..296ef0f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -517,28 +517,6 @@ export interface ProductCompositionImportSummary { }>; } -export interface DataHealthIssue { - type: 'missing_product_sku' | 'component_without_stock_link' | 'suspicious_quantity' | 'raw_or_service_structure' | 'product_without_composition'; - severity: 'critical' | 'attention' | 'info'; - title: string; - detail: string; - productSku: string; - component: string; -} - -export interface DataHealthSummary { - totals: { - products: number; - productsWithComposition: number; - productsWithStockLink: number; - materials: number; - materialsWithStock: number; - compositions: number; - components: number; - }; - issues: DataHealthIssue[]; -} - export interface ClientAnalyticsItem { customerKey: string; clientToken: string;