Remove data health dashboard
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s

This commit is contained in:
Cauê Faleiros
2026-08-03 10:31:23 -03:00
parent e993bfdaf3
commit 7fcf52cf67
7 changed files with 2 additions and 240 deletions

View File

@@ -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)));

View File

@@ -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 };

View File

@@ -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() {
<Route path="replenishment" element={<Replenishment />} />
<Route path="cutting" element={<Cutting />} />
<Route path="planning-issues" element={<PlanningIssues />} />
<Route path="data-health" element={<DataHealth />} />
<Route path="supplies" element={<Supplies />} />
<Route path="supplies/:section" element={<Supplies />} />
<Route path="stock" element={<Navigate to="/products" replace />} />

View File

@@ -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 },
],

View File

@@ -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<ProductComposition[]>
}
};
export const fetchDataHealth = async (): Promise<DataHealthSummary | null> => {
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<number> => {
const response = await authFetch('/analytics/product-compositions');
const data = await response.json().catch(() => null) as { compositions?: ProductComposition[]; error?: string } | null;

View File

@@ -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<DataHealthIssue['severity'], string> = {
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<DataHealthSummary>(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 (
<div className="space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="text-2xl font-bold text-dark-text">Saúde dos Dados</h1>
<p className="mt-1 font-medium text-dark-muted">Confira se composição, vínculo de estoque e consumo estão prontos para orientar compra e corte.</p>
</div>
<button type="button" onClick={() => void load()} className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text hover:border-brand-primary cursor-pointer">
<RefreshCw className={`h-4 w-4 text-brand-primary ${isLoading ? 'animate-spin' : ''}`} /> Atualizar
</button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{metrics.map(metric => (
<div key={metric.label} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{metric.label}</p>
<div className="mt-3 flex items-end justify-between gap-3"><p className="text-3xl font-bold text-dark-text">{metric.value}%</p><p className="text-xs font-semibold text-dark-muted">{metric.detail}</p></div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-dark-border"><div className="h-full rounded-full bg-brand-primary" style={{ width: `${metric.value}%` }} /></div>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
{[
['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]) => (
<button key={type} type="button" onClick={() => { setFilter(type as DataHealthIssue['type']); setCurrentPage(1); }} className={`rounded-xl border p-4 text-left transition-colors cursor-pointer ${filter === type ? 'border-brand-primary/50 bg-brand-primary/10' : 'border-dark-border bg-dark-card hover:border-brand-primary/30'}`}>
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">{label}</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(count(type as DataHealthIssue['type']))}</p>
</button>
))}
</div>
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="flex flex-col gap-3 border-b border-dark-border p-4 md:flex-row">
<input value={search} onChange={event => { 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" />
<select value={filter} onChange={event => { setFilter(event.target.value as typeof filter); setCurrentPage(1); }} className="h-10 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text">
<option value="all">Todas as revisões</option><option value="missing_product_sku">Produtos sem SKU</option><option value="component_without_stock_link">Componentes sem vínculo</option><option value="suspicious_quantity">Quantidades suspeitas</option><option value="raw_or_service_structure">Matéria-prima / serviço</option><option value="product_without_composition">Sem composição</option>
</select>
</div>
{isLoading ? <div className="flex h-48 items-center justify-center text-sm font-bold text-dark-muted"><Database className="mr-2 h-5 w-5 text-brand-primary" /> Verificando dados</div> : !visibleIssues.length ? <div className="flex h-48 flex-col items-center justify-center text-sm font-bold text-emerald-300"><CheckCircle2 className="mb-2 h-7 w-7" /> Nenhuma pendência neste filtro.</div> : (
<><div className="divide-y divide-dark-border">{visibleIssues.map((issue, index) => <div key={`${issue.type}-${issue.productSku}-${issue.component}-${index}`} className="flex gap-3 p-4"><AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" /><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-2"><p className="font-bold text-dark-text">{issue.title}</p><span className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${severityStyle[issue.severity]}`}>{issue.severity === 'critical' ? 'Crítico' : issue.severity === 'attention' ? 'Revisar' : 'Informativo'}</span></div><p className="mt-1 text-sm font-medium text-dark-muted">{issue.detail}</p>{(issue.productSku || issue.component) && <p className="mt-1 font-mono text-[11px] text-dark-muted">{[issue.productSku && `SKU ${issue.productSku}`, issue.component && `Comp. ${issue.component}`].filter(Boolean).join(' · ')}</p>}</div><PackageSearch className="h-4 w-4 shrink-0 text-dark-muted" /></div>)}</div>
<PaginationControls totalItems={filteredIssues.length} currentPage={safePage} totalPages={totalPages} pageSize={itemsPerPage} pageSizeOptions={[20, 50, 100]} itemLabel="pendências" pageSizeLabel="itens por página" startIndex={startIndex} endIndex={Math.min(startIndex + itemsPerPage, filteredIssues.length)} onPageChange={setCurrentPage} onPageSizeChange={size => { setItemsPerPage(size); setCurrentPage(1); }} className="border-t border-dark-border px-4 py-3" /></>
)}
</section>
</div>
);
};
export default DataHealth;

View File

@@ -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;