Use Tiny compositions for purchase planning
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m41s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m41s
This commit is contained in:
@@ -11,6 +11,7 @@ const {
|
||||
getRfmAnalytics
|
||||
} = require('../services/analyticsService');
|
||||
const { getProductComposition, listProductCompositions } = require('../services/productionOrderService');
|
||||
const { getDataHealthSummary } = require('../services/dataHealthService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -78,6 +79,15 @@ 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)));
|
||||
|
||||
85
backend/services/dataHealthService.js
Normal file
85
backend/services/dataHealthService.js
Normal file
@@ -0,0 +1,85 @@
|
||||
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 };
|
||||
@@ -269,6 +269,37 @@ const listConsumptionReferenceRows = async () => {
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
// The Tiny structure is the source of truth when it exists. Consumption
|
||||
// references remain useful as a fallback for legacy/manual products, but they
|
||||
// must not make an imported structure invisible to purchase planning.
|
||||
const listCompositionRows = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
composition.finished_tiny_product_id,
|
||||
composition.finished_product_sku,
|
||||
component.component_tiny_id,
|
||||
component.component_sku,
|
||||
component.component_name,
|
||||
component.quantity_per_unit,
|
||||
component.unit
|
||||
FROM product_compositions composition
|
||||
JOIN product_composition_components component
|
||||
ON component.product_composition_id = composition.id
|
||||
WHERE composition.source = 'tiny_olist_v3'
|
||||
ORDER BY composition.id, component.id;
|
||||
`);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const listTinyStockRows = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT produto_id, nome, COALESCE(saldo, 0)::numeric AS saldo
|
||||
FROM stock
|
||||
WHERE COALESCE(produto_id, '') <> '' OR COALESCE(nome, '') <> '';
|
||||
`);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const mergeNeedLine = (needsByMaterial, key, patch) => {
|
||||
const current = needsByMaterial.get(key) || {
|
||||
material: patch.material,
|
||||
@@ -302,9 +333,11 @@ const mergeNeedLine = (needsByMaterial, key, patch) => {
|
||||
};
|
||||
|
||||
const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
const [demandRows, referenceRows] = await Promise.all([
|
||||
const [demandRows, referenceRows, compositionRows, tinyStockRows] = await Promise.all([
|
||||
listProjectDemandRows(),
|
||||
listConsumptionReferenceRows()
|
||||
listConsumptionReferenceRows(),
|
||||
listCompositionRows(),
|
||||
listTinyStockRows()
|
||||
]);
|
||||
const referencesBySku = referenceRows.reduce((references, reference) => {
|
||||
const sku = normalizeSku(reference.product_sku);
|
||||
@@ -314,6 +347,70 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
}, new Map());
|
||||
const needsByMaterial = new Map();
|
||||
|
||||
const compositionsByProduct = compositionRows.reduce((compositions, component) => {
|
||||
[component.finished_tiny_product_id, component.finished_product_sku]
|
||||
.map(normalizeSku)
|
||||
.filter(Boolean)
|
||||
.forEach(identity => {
|
||||
compositions.set(identity, [...(compositions.get(identity) || []), component]);
|
||||
});
|
||||
return compositions;
|
||||
}, new Map());
|
||||
|
||||
// A need is keyed by the imported Tiny component identity whenever
|
||||
// possible. Names are retained for display and for matching manual lots.
|
||||
const componentNeedKeys = new Map();
|
||||
const registerNeedKey = (identity, key) => {
|
||||
const normalizedIdentity = normalizeSku(identity);
|
||||
if (normalizedIdentity) componentNeedKeys.set(normalizedIdentity, key);
|
||||
};
|
||||
const addCompositionNeed = (component, row, productId, suggestedQuantity, quantitySold, stockQuantity) => {
|
||||
const unit = normalizeUnit(component.unit);
|
||||
const material = normalizeText(component.component_name)
|
||||
|| normalizeText(component.component_sku)
|
||||
|| normalizeText(component.component_tiny_id)
|
||||
|| 'Material sem cadastro';
|
||||
const quantityPerUnit = Number(component.quantity_per_unit || 0);
|
||||
const tinyId = normalizeText(component.component_tiny_id);
|
||||
const sku = normalizeSku(component.component_sku);
|
||||
const identity = tinyId || sku || normalizeKey(material);
|
||||
const key = `${unit}:${normalizeSku(identity) || normalizeKey(material)}`;
|
||||
|
||||
if (!Number.isFinite(quantityPerUnit) || quantityPerUnit <= 0) {
|
||||
mergeNeedLine(needsByMaterial, `missing-quantity:${productId}:${identity}`, {
|
||||
material: `Revisar quantidade: ${normalizeText(row.product_name) || productId}`,
|
||||
plannedKg: suggestedQuantity,
|
||||
priority: 'Crítico',
|
||||
unit: 'un.',
|
||||
source: 'tiny_composition',
|
||||
missingReference: true,
|
||||
product: { productId, name: normalizeText(row.product_name), suggestedQuantity, quantitySold, stockQuantity }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
mergeNeedLine(needsByMaterial, key, {
|
||||
material,
|
||||
plannedKg: suggestedQuantity * quantityPerUnit,
|
||||
priority: 'Atenção',
|
||||
unit,
|
||||
source: 'tiny_composition',
|
||||
product: {
|
||||
productId,
|
||||
name: normalizeText(row.product_name),
|
||||
suggestedQuantity,
|
||||
quantitySold,
|
||||
stockQuantity,
|
||||
consumptionQuantity: quantityPerUnit,
|
||||
consumptionUnit: unit
|
||||
}
|
||||
});
|
||||
registerNeedKey(tinyId, key);
|
||||
registerNeedKey(sku, key);
|
||||
// Lots are normally registered by material name, not Tiny ID.
|
||||
registerNeedKey(material, key);
|
||||
};
|
||||
|
||||
demandRows.forEach(row => {
|
||||
const productId = normalizeSku(row.product_id);
|
||||
if (!productId) return;
|
||||
@@ -324,6 +421,12 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
const suggestedQuantity = Math.max(Math.ceil(projectedDemand - stockQuantity), 0);
|
||||
if (suggestedQuantity <= 0) return;
|
||||
|
||||
const composition = compositionsByProduct.get(productId) || [];
|
||||
if (composition.length) {
|
||||
composition.forEach(component => addCompositionNeed(component, row, productId, suggestedQuantity, quantitySold, stockQuantity));
|
||||
return;
|
||||
}
|
||||
|
||||
const references = referencesBySku.get(productId) || [];
|
||||
if (!references.length) {
|
||||
mergeNeedLine(needsByMaterial, `missing:${productId}`, {
|
||||
@@ -410,14 +513,32 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
});
|
||||
});
|
||||
|
||||
const getNeedForInventory = (unit, id, name) => {
|
||||
const normalizedUnit = normalizeUnit(unit);
|
||||
const key = componentNeedKeys.get(normalizeSku(id)) || componentNeedKeys.get(normalizeSku(name));
|
||||
return key && key.startsWith(`${normalizedUnit}:`) ? needsByMaterial.get(key) : undefined;
|
||||
};
|
||||
|
||||
// Tiny saldo is material stock too. It is matched using the component ID
|
||||
// first, then SKU/name, which lets "MALHA PRETA" stock directly reduce the
|
||||
// material purchase suggestion imported from the product structure.
|
||||
tinyStockRows.forEach(stock => {
|
||||
const need = getNeedForInventory('un.', stock.produto_id, stock.nome)
|
||||
|| getNeedForInventory('kg', stock.produto_id, stock.nome)
|
||||
|| getNeedForInventory('', stock.produto_id, stock.nome);
|
||||
if (need) need.stockKg += Number(stock.saldo || 0);
|
||||
});
|
||||
|
||||
lots.forEach(lot => {
|
||||
const need = needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`);
|
||||
const need = getNeedForInventory(lot.unit, '', lot.product)
|
||||
|| needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`);
|
||||
if (need) need.stockKg += lot.quantity;
|
||||
});
|
||||
|
||||
receipts.forEach(receipt => {
|
||||
if (receipt.status !== 'pending') return;
|
||||
const need = needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`);
|
||||
const need = getNeedForInventory(receipt.unit, '', receipt.product)
|
||||
|| needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`);
|
||||
if (need) need.pendingKg += receipt.quantity;
|
||||
});
|
||||
|
||||
@@ -462,9 +583,11 @@ const mergePurchaseNeeds = (manualNeeds, projectNeeds) => {
|
||||
}
|
||||
|
||||
current.plannedKg += need.plannedKg;
|
||||
current.stockKg += need.stockKg;
|
||||
current.pendingKg += need.pendingKg;
|
||||
current.purchaseKg += need.purchaseKg;
|
||||
// Stock/receipts are the same physical inventory for a manual plan and
|
||||
// an imported-composition need. They are alternatives views of the
|
||||
// balance, never amounts to add together.
|
||||
current.stockKg = Math.max(current.stockKg, need.stockKg);
|
||||
current.pendingKg = Math.max(current.pendingKg, need.pendingKg);
|
||||
current.priority = need.priority === 'Crítico' || current.priority === 'Crítico'
|
||||
? 'Crítico'
|
||||
: need.priority === 'Atenção' || current.priority === 'Atenção'
|
||||
@@ -483,11 +606,18 @@ const mergePurchaseNeeds = (manualNeeds, projectNeeds) => {
|
||||
});
|
||||
|
||||
return Array.from(mergedByKey.values())
|
||||
.map(need => ({
|
||||
...need,
|
||||
suppliers: Array.from(need.suppliers),
|
||||
colors: Array.from(need.colors)
|
||||
}))
|
||||
.map(need => {
|
||||
const purchaseKg = Math.max(need.plannedKg - need.stockKg - need.pendingKg, 0);
|
||||
return {
|
||||
...need,
|
||||
purchaseKg,
|
||||
status: purchaseKg > 0
|
||||
? (need.priority === 'Crítico' || need.stockKg === 0 ? 'critical' : 'attention')
|
||||
: 'ok',
|
||||
suppliers: Array.from(need.suppliers),
|
||||
colors: Array.from(need.colors)
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const statusOrder = { critical: 1, attention: 2, ok: 3 };
|
||||
return statusOrder[a.status] - statusOrder[b.status] || b.purchaseKg - a.purchaseKg || a.material.localeCompare(b.material);
|
||||
|
||||
@@ -11,6 +11,7 @@ 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'));
|
||||
@@ -56,6 +57,7 @@ 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 />} />
|
||||
|
||||
@@ -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 } from 'lucide-react';
|
||||
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList, Database } from 'lucide-react';
|
||||
import type { DateRange, OrderData } from '../types';
|
||||
import { isSuperAdmin, logout } from '../dataService';
|
||||
import { rangeForLastDays } from '../dateRanges';
|
||||
@@ -74,6 +74,7 @@ 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 },
|
||||
],
|
||||
|
||||
@@ -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, 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, 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 { formatDateParam } from './dateRanges';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
@@ -567,6 +567,29 @@ export const fetchProductComposition = async (productId: string): Promise<Produc
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchProductCompositions = async (): Promise<ProductComposition[]> => {
|
||||
try {
|
||||
const response = await authFetch('/analytics/product-compositions');
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json() as { compositions?: ProductComposition[] };
|
||||
return data.compositions || [];
|
||||
} catch (error) {
|
||||
console.error('Fetch product compositions failed', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -7,8 +7,9 @@ import ProductColorBadge from '../components/ProductColorBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { buildCuttingSkuConfigPath } from '../catalogLinks';
|
||||
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
|
||||
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService';
|
||||
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchStock, saveCuttingSettings } from '../dataService';
|
||||
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, StockData } from '../types';
|
||||
import { getPlanningStock } from '../planningStock';
|
||||
|
||||
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
|
||||
type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc';
|
||||
@@ -17,6 +18,7 @@ type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>;
|
||||
type CorrectionIssueFilter = CutProductIssue | 'all';
|
||||
type SettingsSection = 'rules' | 'corrections';
|
||||
type CuttingView = 'plan' | 'families' | 'issues';
|
||||
type MaterialReadiness = { status: 'ready' | 'blocked' | 'missing'; blockers: string[] };
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings';
|
||||
const coverageTargetOptions = [7, 15, 30, 60];
|
||||
@@ -66,6 +68,13 @@ const formatNumber = (value: number, maximumFractionDigits = 0) => (
|
||||
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
|
||||
);
|
||||
|
||||
const normalizeMaterialKey = (value: string) => value
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
|
||||
const formatDateKey = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
@@ -128,6 +137,8 @@ const Cutting = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
||||
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
|
||||
const [compositions, setCompositions] = useState<ProductComposition[]>([]);
|
||||
const [materialStock, setMaterialStock] = useState<StockData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [targetCoverageDays, setTargetCoverageDays] = useState(30);
|
||||
@@ -181,13 +192,17 @@ const Cutting = () => {
|
||||
|
||||
const loadProducts = async () => {
|
||||
setIsLoading(true);
|
||||
const [productData, productionOrderData] = await Promise.all([
|
||||
const [productData, productionOrderData, compositionData, stockData] = await Promise.all([
|
||||
fetchProductAnalytics(dateRange),
|
||||
fetchProductionOrders(allProductionOrdersRange)
|
||||
fetchProductionOrders(allProductionOrdersRange),
|
||||
fetchProductCompositions(),
|
||||
fetchStock()
|
||||
]);
|
||||
if (isMounted) {
|
||||
setProducts(productData);
|
||||
setProductionOrders(productionOrderData.orders);
|
||||
setCompositions(compositionData);
|
||||
setMaterialStock(stockData);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -209,6 +224,35 @@ const Cutting = () => {
|
||||
[cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays]
|
||||
);
|
||||
|
||||
const materialReadinessByProductId = useMemo(() => {
|
||||
const compositionsById = new Map<string, ProductComposition>();
|
||||
compositions.forEach(composition => {
|
||||
[composition.finishedTinyProductId, composition.finishedProductSku]
|
||||
.map(normalizeMaterialKey)
|
||||
.filter(Boolean)
|
||||
.forEach(key => compositionsById.set(key, composition));
|
||||
});
|
||||
const readinessByProductId = new Map<string, MaterialReadiness>();
|
||||
cutPlan.rows.forEach(row => {
|
||||
const composition = compositionsById.get(normalizeMaterialKey(row.id));
|
||||
if (!composition?.components.length) {
|
||||
readinessByProductId.set(row.id, { status: 'missing', blockers: [] });
|
||||
return;
|
||||
}
|
||||
const blockers = composition.components.flatMap(component => {
|
||||
const stockKeys = new Set([component.componentTinyId, component.componentSku, component.productId || '', component.componentName].map(normalizeMaterialKey).filter(Boolean));
|
||||
const match = materialStock.find(stock => stockKeys.has(normalizeMaterialKey(stock.produto_id)) || stockKeys.has(normalizeMaterialKey(stock.nome)));
|
||||
const available = match ? getPlanningStock(match.saldo) : null;
|
||||
const required = row.suggestedCutQuantity * component.quantityPerUnit;
|
||||
return available === null || available < required
|
||||
? [`${component.componentName}${available === null ? ' (sem estoque vinculado)' : ` (${formatNumber(available)} / ${formatNumber(required)} ${component.unit || ''})`}`]
|
||||
: [];
|
||||
});
|
||||
readinessByProductId.set(row.id, { status: blockers.length ? 'blocked' : 'ready', blockers });
|
||||
});
|
||||
return readinessByProductId;
|
||||
}, [compositions, cutPlan.rows, materialStock]);
|
||||
|
||||
const issueSummaries = useMemo(() => {
|
||||
const counts = new Map<CutIssue, number>();
|
||||
cutPlan.needRows.forEach(row => {
|
||||
@@ -254,8 +298,8 @@ const Cutting = () => {
|
||||
}, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]);
|
||||
|
||||
const rowsAvailableForOrder = useMemo(() => (
|
||||
filteredRows.filter(row => row.suggestedCutQuantity > 0)
|
||||
), [filteredRows]);
|
||||
filteredRows.filter(row => row.suggestedCutQuantity > 0 && materialReadinessByProductId.get(row.id)?.status === 'ready')
|
||||
), [filteredRows, materialReadinessByProductId]);
|
||||
|
||||
const orderPreviewTotalUnits = useMemo(() => (
|
||||
rowsAvailableForOrder.reduce((total, row) => total + row.suggestedCutQuantity, 0)
|
||||
@@ -420,13 +464,15 @@ const Cutting = () => {
|
||||
'Rendimento un/rolo': row.family.unitsPerRoll || '',
|
||||
'Rolos estimados': row.estimatedRolls || '',
|
||||
'Cobertura': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','),
|
||||
'Pronto para cortar': materialReadinessByProductId.get(row.id)?.status === 'ready' ? 'Sim' : materialReadinessByProductId.get(row.id)?.status === 'blocked' ? 'Não - falta material' : 'Sem composição',
|
||||
'Materiais bloqueando': materialReadinessByProductId.get(row.id)?.blockers.join(' | ') || '',
|
||||
'Dados pendentes': row.issues.map(issue => issueLabels[issue]).join(' | ')
|
||||
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
};
|
||||
|
||||
const openProductionOrderPreview = () => {
|
||||
if (!rowsAvailableForOrder.length) {
|
||||
setGenerationMessage('Não há necessidade de corte no filtro atual.');
|
||||
setGenerationMessage('Não há SKU com necessidade e materiais disponíveis no filtro atual.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -436,7 +482,7 @@ const Cutting = () => {
|
||||
|
||||
const generateProductionOrders = async () => {
|
||||
if (!rowsAvailableForOrder.length) {
|
||||
setGenerationMessage('Não há necessidade de corte no filtro atual.');
|
||||
setGenerationMessage('Não há SKU com necessidade e materiais disponíveis no filtro atual.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -459,7 +505,7 @@ const Cutting = () => {
|
||||
markers: [
|
||||
{ label: row.family.materialLabel, color: '#38bdf8' },
|
||||
...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []),
|
||||
{ label: 'Material pendente', color: '#facc15' },
|
||||
{ label: 'Material confirmado', color: '#52DFA0' },
|
||||
...(row.issues.length ? [{ label: 'Dados pendentes', color: '#f59e0b' }] : [])
|
||||
],
|
||||
metadata: {
|
||||
@@ -509,6 +555,13 @@ const Cutting = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderMaterialReadiness = (row: CutPlanSkuRow) => {
|
||||
const readiness = materialReadinessByProductId.get(row.id);
|
||||
if (readiness?.status === 'ready') return <span className="text-xs font-bold text-emerald-300">Pode cortar</span>;
|
||||
if (readiness?.status === 'missing') return <span className="text-xs font-bold text-amber-300">Sem composição</span>;
|
||||
return <span className="inline-flex max-w-[170px] truncate rounded-full border border-red-400/30 bg-red-400/10 px-2 py-1 text-xs font-bold text-red-300" title={readiness?.blockers.join(' · ')}>Falta: {readiness?.blockers[0] || 'material'}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
|
||||
@@ -1209,7 +1262,7 @@ const Cutting = () => {
|
||||
) : (
|
||||
<div className={`overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-dark-border dark:bg-dark-card ${isRefreshing ? 'refreshing-content' : ''}`} aria-busy={isRefreshing}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1180px] table-fixed text-left text-sm">
|
||||
<table className="w-full min-w-[1320px] table-fixed text-left text-sm">
|
||||
<colgroup>
|
||||
<col className="w-[105px]" />
|
||||
<col className="w-[320px]" />
|
||||
@@ -1220,6 +1273,7 @@ const Cutting = () => {
|
||||
<col className="w-[140px]" />
|
||||
<col className="w-[80px]" />
|
||||
<col className="w-[130px]" />
|
||||
<col className="w-[170px]" />
|
||||
<col className="w-[110px]" />
|
||||
</colgroup>
|
||||
<thead className="border-b border-zinc-100 bg-zinc-50 text-zinc-500 dark:border-dark-border dark:bg-dark-header dark:text-dark-muted">
|
||||
@@ -1232,6 +1286,7 @@ const Cutting = () => {
|
||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Estoque</th>
|
||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
|
||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Rolos</th>
|
||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Materiais</th>
|
||||
<th
|
||||
className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider"
|
||||
title="Dados que ainda faltam para fechar o plano de corte do SKU."
|
||||
@@ -1280,6 +1335,7 @@ const Cutting = () => {
|
||||
<td className="px-4 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">
|
||||
{row.estimatedRolls === null ? '-' : formatNumber(row.estimatedRolls)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">{renderMaterialReadiness(row)}</td>
|
||||
<td className="px-4 py-2.5">{renderIssueBadge(row)}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
107
src/pages/DataHealth.tsx
Normal file
107
src/pages/DataHealth.tsx
Normal file
@@ -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<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;
|
||||
@@ -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<ProductDetailsAnalytics | null>(null);
|
||||
const [composition, setComposition] = useState<ProductComposition | null>(null);
|
||||
const [materialStock, setMaterialStock] = useState<StockData[]>([]);
|
||||
const [isCompositionLoading, setIsCompositionLoading] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [chartMetric, setChartMetric] = useState<ProductChartMetric>('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 (
|
||||
<div className="space-y-6">
|
||||
@@ -414,12 +446,23 @@ const ProductDetails = () => {
|
||||
</div>
|
||||
|
||||
<section className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
||||
<div className="mb-5">
|
||||
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3>
|
||||
{composition && (
|
||||
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">
|
||||
Para produzir 1 UN de {composition.finishedProductSku || productInfo.id}
|
||||
</p>
|
||||
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3>
|
||||
{composition && (
|
||||
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">
|
||||
Necessidade para as {formatNumber(totalSold)} un. vendidas no período e saldo atual do Tiny.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{blockingMaterial && (
|
||||
<div className={`rounded-xl border px-3 py-2 text-xs font-bold ${
|
||||
(blockingMaterial.productionCapacity || 0) < totalSold
|
||||
? 'border-red-400/30 bg-red-400/10 text-red-300'
|
||||
: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
|
||||
}`}>
|
||||
Limitante: {blockingMaterial.component.componentName} · {formatNumber(blockingMaterial.productionCapacity || 0)} un. possíveis
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCompositionLoading ? (
|
||||
@@ -434,17 +477,20 @@ const ProductDetails = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-dark-border">
|
||||
<table className="w-full min-w-[640px] text-left text-sm">
|
||||
<table className="w-full min-w-[920px] text-left text-sm">
|
||||
<thead className="bg-dark-input/60 text-[10px] font-bold uppercase tracking-widest text-dark-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Produto / insumo</th>
|
||||
<th className="px-4 py-3">SKU</th>
|
||||
<th className="px-4 py-3 text-right">Quantidade por unidade</th>
|
||||
<th className="px-4 py-3">Unidade</th>
|
||||
<th className="px-4 py-3 text-right">Necessário no período</th>
|
||||
<th className="px-4 py-3 text-right">Estoque Tiny</th>
|
||||
<th className="px-4 py-3 text-right">Cobertura</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-border">
|
||||
{composition.components.map(component => (
|
||||
{materialPlan.map(({ component, requiredForPeriod, availableStock, productionCapacity, periodCoverage }) => (
|
||||
<tr key={component.id} className="text-dark-text">
|
||||
<td className="px-4 py-3 font-semibold">
|
||||
{component.productId ? (
|
||||
@@ -456,6 +502,13 @@ const ProductDetails = () => {
|
||||
<td className="px-4 py-3 font-mono text-xs text-dark-muted">{component.componentSku || '—'}</td>
|
||||
<td className="px-4 py-3 text-right font-semibold">{formatNumber(component.quantityPerUnit)}</td>
|
||||
<td className="px-4 py-3 text-dark-muted">{component.unit || '—'}</td>
|
||||
<td className="px-4 py-3 text-right font-semibold">{formatNumber(requiredForPeriod)} {component.unit || ''}</td>
|
||||
<td className="px-4 py-3 text-right font-semibold">{availableStock === null ? 'Sem vínculo' : `${formatNumber(availableStock)} ${component.unit || ''}`}</td>
|
||||
<td className={`px-4 py-3 text-right font-bold ${availableStock === null || (productionCapacity || 0) < totalSold ? 'text-red-300' : 'text-emerald-300'}`}>
|
||||
{availableStock === null
|
||||
? 'Revisar link'
|
||||
: `${formatNumber(productionCapacity || 0)} un. · ${periodCoverage === null ? '-' : `${formatNumber(periodCoverage)} dias`}`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1997,8 +1997,9 @@ const PurchaseNeedsScreen = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<table className="w-full min-w-[920px] table-fixed border-collapse">
|
||||
<table className="w-full min-w-[1120px] table-fixed border-collapse">
|
||||
<colgroup>
|
||||
<col className="w-[280px]" />
|
||||
<col />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[120px]" />
|
||||
@@ -2008,9 +2009,10 @@ const PurchaseNeedsScreen = () => {
|
||||
</colgroup>
|
||||
<thead className="border-b border-dark-border bg-dark-input/40 text-xs font-bold uppercase tracking-widest text-dark-muted">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 text-left">Material</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Planejado</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Estoque</th>
|
||||
<th scope="col" className="px-4 py-3 text-left">Demanda de produto</th>
|
||||
<th scope="col" className="px-4 py-3 text-left">Material necessário</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Necessário</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Estoque atual</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Pendente</th>
|
||||
<th scope="col" className="px-4 py-3 text-right">Comprar</th>
|
||||
<th scope="col" className="px-4 py-3 text-left">Cobertura</th>
|
||||
@@ -2021,6 +2023,19 @@ const PurchaseNeedsScreen = () => {
|
||||
const referenceProduct = need.products?.[0];
|
||||
return (
|
||||
<tr key={need.material}>
|
||||
<td className="px-4 py-4 align-middle">
|
||||
{need.products?.length ? (
|
||||
<div className="min-w-0">
|
||||
{need.products.slice(0, 2).map(product => (
|
||||
<div key={product.productId} className="mb-1 min-w-0 last:mb-0" title={product.name}>
|
||||
<p className="truncate text-xs font-bold text-dark-text">{product.name || product.productId}</p>
|
||||
<p className="text-[10px] font-semibold text-dark-muted">SKU {product.productId} · demanda {formatNumber(product.suggestedQuantity)} un.</p>
|
||||
</div>
|
||||
))}
|
||||
{need.products.length > 2 && <p className="mt-1 text-[10px] font-semibold text-dark-muted">+{need.products.length - 2} SKUs impactados</p>}
|
||||
</div>
|
||||
) : <span className="text-xs font-semibold text-dark-muted">Plano manual</span>}
|
||||
</td>
|
||||
<td className="px-4 py-4 align-middle">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-bold text-dark-text">{getNeedDisplayName(need)}</p>
|
||||
|
||||
22
src/types.ts
22
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;
|
||||
|
||||
Reference in New Issue
Block a user