Simplify product composition details
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m14s

This commit is contained in:
Cauê Faleiros
2026-08-03 11:09:00 -03:00
parent 635103b924
commit 6d0d8e6cf0

View File

@@ -7,8 +7,8 @@ import DateRangePicker from '../components/DateRangePicker';
import SkuPlanningModal from '../components/SkuPlanningModal'; import SkuPlanningModal from '../components/SkuPlanningModal';
import ProductTypeBadge from '../components/ProductTypeBadge'; import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics, StockData } from '../types'; import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types';
import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, fetchStock, saveCuttingSettings } from '../dataService'; import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService';
import { parseProductName } from '../productParsing'; import { parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters'; import { formatColorLabel } from '../displayFormatters';
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils'; import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
@@ -132,7 +132,6 @@ const ProductDetails = () => {
}>(); }>();
const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null); const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null);
const [composition, setComposition] = useState<ProductComposition | null>(null); const [composition, setComposition] = useState<ProductComposition | null>(null);
const [materialStock, setMaterialStock] = useState<StockData[]>([]);
const [isCompositionLoading, setIsCompositionLoading] = useState(true); const [isCompositionLoading, setIsCompositionLoading] = useState(true);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity'); const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity');
@@ -172,16 +171,14 @@ const ProductDetails = () => {
setIsLoading(true); setIsLoading(true);
setIsCompositionLoading(true); setIsCompositionLoading(true);
const [productDetails, productComposition, stock] = await Promise.all([ const [productDetails, productComposition] = await Promise.all([
fetchProductDetailsAnalytics(id, dateRange), fetchProductDetailsAnalytics(id, dateRange),
fetchProductComposition(id), fetchProductComposition(id)
fetchStock()
]); ]);
if (isMounted) { if (isMounted) {
setDetails(productDetails); setDetails(productDetails);
setComposition(productComposition); setComposition(productComposition);
setMaterialStock(stock);
setIsLoading(false); setIsLoading(false);
setIsCompositionLoading(false); setIsCompositionLoading(false);
} }
@@ -334,41 +331,6 @@ const ProductDetails = () => {
: formatDateBucketLongLabel(selectedProductPoint.date, dateBucket) : formatDateBucketLongLabel(selectedProductPoint.date, dateBucket)
: ''; : '';
const maxVariantQuantity = Math.max(...variantBreakdown.map(variant => variant.quantitySold), 0); 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 isServiceComponent = (name: string) => /\b(?:SERVI[CÇ]O|FRETE|TINTURARIA|TECELAGEM|COSTURA|ESTAMPARIA|LAVANDERIA)\b/i.test(name);
const isSuspiciousConsumption = (quantity: number, unit: string) => (
['kg', 'quilo', 'quilos'].includes(unit.trim().toLowerCase()) && quantity >= 0.5
);
const materialPlan = (composition?.components || []).map(component => {
const isService = isServiceComponent(component.componentName);
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 = isService ? null : 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, isService, suspicious: isSuspiciousConsumption(component.quantityPerUnit, component.unit) };
});
const blockingMaterial = materialPlan
.filter(item => !item.isService && item.productionCapacity !== null)
.sort((a, b) => (a.productionCapacity || 0) - (b.productionCapacity || 0))[0];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
@@ -451,24 +413,15 @@ const ProductDetails = () => {
</div> </div>
<section className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm"> <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 flex flex-col gap-3 md:flex-row md:items-end md:justify-between"> <div className="mb-5">
<div> <div>
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3> <h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3>
{composition && ( {composition && (
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted"> <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. Para produzir 1 UN de {composition.finishedProductSku || productInfo.id}
</p> </p>
)} )}
</div> </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> </div>
{isCompositionLoading ? ( {isCompositionLoading ? (
<div className="flex h-24 items-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">Carregando composição</div> <div className="flex h-24 items-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">Carregando composição</div>
@@ -482,21 +435,17 @@ const ProductDetails = () => {
</div> </div>
) : ( ) : (
<div className="overflow-x-auto rounded-xl border border-dark-border"> <div className="overflow-x-auto rounded-xl border border-dark-border">
<table className="w-full min-w-[1040px] text-left text-sm"> <table className="w-full min-w-[640px] text-left text-sm">
<thead className="bg-dark-input/60 text-[10px] font-bold uppercase tracking-widest text-dark-muted"> <thead className="bg-dark-input/60 text-[10px] font-bold uppercase tracking-widest text-dark-muted">
<tr> <tr>
<th className="px-4 py-3">Produto / insumo</th> <th className="px-4 py-3">Produto / insumo</th>
<th className="px-4 py-3">SKU</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 text-right">Quantidade por unidade</th>
<th className="px-4 py-3">Unidade</th> <th className="px-4 py-3">Unidade</th>
<th className="px-4 py-3">Validação</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> </tr>
</thead> </thead>
<tbody className="divide-y divide-dark-border"> <tbody className="divide-y divide-dark-border">
{materialPlan.map(({ component, requiredForPeriod, availableStock, productionCapacity, periodCoverage, isService, suspicious }) => ( {composition.components.map(component => (
<tr key={component.id} className="text-dark-text"> <tr key={component.id} className="text-dark-text">
<td className="px-4 py-3 font-semibold"> <td className="px-4 py-3 font-semibold">
{component.productId ? ( {component.productId ? (
@@ -508,19 +457,6 @@ const ProductDetails = () => {
<td className="px-4 py-3 font-mono text-xs text-dark-muted">{component.componentSku || '—'}</td> <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-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-dark-muted">{component.unit || '—'}</td>
<td className="px-4 py-3">
{isService ? <span className="rounded-full border border-sky-400/30 bg-sky-400/10 px-2 py-1 text-[10px] font-bold text-sky-300">Serviço</span>
: suspicious ? <span className="rounded-full border border-amber-400/30 bg-amber-400/10 px-2 py-1 text-[10px] font-bold text-amber-300">Revisar consumo</span>
: <span className="text-xs font-bold text-emerald-300">OK</span>}
</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">{isService ? 'Não aplicável' : availableStock === null ? 'Sem vínculo' : `${formatNumber(availableStock)} ${component.unit || ''}`}</td>
<td className={`px-4 py-3 text-right font-bold ${isService ? 'text-sky-300' : availableStock === null || (productionCapacity || 0) < totalSold ? 'text-red-300' : 'text-emerald-300'}`}>
{isService ? 'Terceirizar'
: availableStock === null
? 'Revisar link'
: `${formatNumber(productionCapacity || 0)} un. · ${periodCoverage === null ? '-' : periodCoverage > 365 ? '> 365 dias' : `${formatNumber(periodCoverage)} dias`}`}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>