@@ -1232,6 +1286,7 @@ const Cutting = () => {
| Estoque |
Necessidade |
Rolos |
+ Materiais |
{
|
{row.estimatedRolls === null ? '-' : formatNumber(row.estimatedRolls)}
|
+ {renderMaterialReadiness(row)} |
{renderIssueBadge(row)} |
diff --git a/src/pages/DataHealth.tsx b/src/pages/DataHealth.tsx
new file mode 100644
index 0000000..b7d1e99
--- /dev/null
+++ b/src/pages/DataHealth.tsx
@@ -0,0 +1,107 @@
+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/pages/ProductDetails.tsx b/src/pages/ProductDetails.tsx
index f7b46f1..301db40 100644
--- a/src/pages/ProductDetails.tsx
+++ b/src/pages/ProductDetails.tsx
@@ -7,8 +7,8 @@ import DateRangePicker from '../components/DateRangePicker';
import SkuPlanningModal from '../components/SkuPlanningModal';
import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus';
-import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types';
-import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService';
+import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics, StockData } from '../types';
+import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, fetchStock, saveCuttingSettings } from '../dataService';
import { parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
@@ -132,6 +132,7 @@ const ProductDetails = () => {
}>();
const [details, setDetails] = useState(null);
const [composition, setComposition] = useState(null);
+ const [materialStock, setMaterialStock] = useState([]);
const [isCompositionLoading, setIsCompositionLoading] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [chartMetric, setChartMetric] = useState('quantity');
@@ -171,14 +172,16 @@ const ProductDetails = () => {
setIsLoading(true);
setIsCompositionLoading(true);
- const [productDetails, productComposition] = await Promise.all([
+ const [productDetails, productComposition, stock] = await Promise.all([
fetchProductDetailsAnalytics(id, dateRange),
- fetchProductComposition(id)
+ fetchProductComposition(id),
+ fetchStock()
]);
if (isMounted) {
setDetails(productDetails);
setComposition(productComposition);
+ setMaterialStock(stock);
setIsLoading(false);
setIsCompositionLoading(false);
}
@@ -331,6 +334,35 @@ const ProductDetails = () => {
: formatDateBucketLongLabel(selectedProductPoint.date, dateBucket)
: '';
const maxVariantQuantity = Math.max(...variantBreakdown.map(variant => variant.quantitySold), 0);
+ const normalizeMaterialKey = (value: string) => value
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .toUpperCase();
+ const materialPlan = (composition?.components || []).map(component => {
+ const candidates = new Set([
+ component.componentTinyId,
+ component.componentSku,
+ component.productId || '',
+ component.componentName
+ ].map(normalizeMaterialKey).filter(Boolean));
+ const stockMatch = materialStock.find(item => (
+ candidates.has(normalizeMaterialKey(item.produto_id)) || candidates.has(normalizeMaterialKey(item.nome))
+ ));
+ const availableStock = stockMatch ? getPlanningStock(stockMatch.saldo) : null;
+ const requiredForPeriod = totalSold * component.quantityPerUnit;
+ const productionCapacity = availableStock === null || component.quantityPerUnit <= 0
+ ? null
+ : Math.floor(availableStock / component.quantityPerUnit);
+ const periodCoverage = requiredForPeriod > 0 && availableStock !== null
+ ? (availableStock / requiredForPeriod) * periodDays
+ : null;
+ return { component, availableStock, requiredForPeriod, productionCapacity, periodCoverage };
+ });
+ const blockingMaterial = materialPlan
+ .filter(item => item.productionCapacity !== null)
+ .sort((a, b) => (a.productionCapacity || 0) - (b.productionCapacity || 0))[0];
return (
@@ -414,12 +446,23 @@ const ProductDetails = () => {
-
- Composição
- {composition && (
-
- Para produzir 1 UN de {composition.finishedProductSku || productInfo.id}
-
+
+
+ Composição
+ {composition && (
+
+ Necessidade para as {formatNumber(totalSold)} un. vendidas no período e saldo atual do Tiny.
+
+ )}
+
+ {blockingMaterial && (
+
+ Limitante: {blockingMaterial.component.componentName} · {formatNumber(blockingMaterial.productionCapacity || 0)} un. possíveis
+
)}
{isCompositionLoading ? (
@@ -434,17 +477,20 @@ const ProductDetails = () => {
) : (
-
+
| Produto / insumo |
SKU |
Quantidade por unidade |
Unidade |
+ Necessário no período |
+ Estoque Tiny |
+ Cobertura |
- {composition.components.map(component => (
+ {materialPlan.map(({ component, requiredForPeriod, availableStock, productionCapacity, periodCoverage }) => (
|
{component.productId ? (
@@ -456,6 +502,13 @@ const ProductDetails = () => {
| {component.componentSku || '—'} |
{formatNumber(component.quantityPerUnit)} |
{component.unit || '—'} |
+ {formatNumber(requiredForPeriod)} {component.unit || ''} |
+ {availableStock === null ? 'Sem vínculo' : `${formatNumber(availableStock)} ${component.unit || ''}`} |
+
+ {availableStock === null
+ ? 'Revisar link'
+ : `${formatNumber(productionCapacity || 0)} un. · ${periodCoverage === null ? '-' : `${formatNumber(periodCoverage)} dias`}`}
+ |
))}
diff --git a/src/pages/Supplies.tsx b/src/pages/Supplies.tsx
index 5e5a2cb..d7c8754 100644
--- a/src/pages/Supplies.tsx
+++ b/src/pages/Supplies.tsx
@@ -1997,8 +1997,9 @@ const PurchaseNeedsScreen = () => {
) : (
-
+
+
@@ -2008,9 +2009,10 @@ const PurchaseNeedsScreen = () => {
- | Material |
- Planejado |
- Estoque |
+ Demanda de produto |
+ Material necessário |
+ Necessário |
+ Estoque atual |
Pendente |
Comprar |
Cobertura |
@@ -2021,6 +2023,19 @@ const PurchaseNeedsScreen = () => {
const referenceProduct = need.products?.[0];
return (
+
+ {need.products?.length ? (
+
+ {need.products.slice(0, 2).map(product => (
+
+ {product.name || product.productId}
+ SKU {product.productId} · demanda {formatNumber(product.suggestedQuantity)} un.
+
+ ))}
+ {need.products.length > 2 && +{need.products.length - 2} SKUs impactados }
+
+ ) : Plano manual}
+ |
{getNeedDisplayName(need)}
diff --git a/src/types.ts b/src/types.ts
index 296ef0f..62a1139 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -517,6 +517,28 @@ 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;
| |