Add current data supply planning
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m34s

This commit is contained in:
Cauê Faleiros
2026-07-16 14:34:07 -03:00
parent 4bfd0d4591
commit e62ec6052e

View File

@@ -1,5 +1,5 @@
import { type FormEvent, useEffect, useState } from 'react'; import { type FormEvent, useEffect, useMemo, useState } from 'react';
import { Link as RouterLink, Navigate, useParams } from 'react-router-dom'; import { Link as RouterLink, Navigate, useOutletContext, useParams } from 'react-router-dom';
import { import {
AlertTriangle, AlertTriangle,
ArrowLeft, ArrowLeft,
@@ -11,6 +11,7 @@ import {
Download, Download,
Link as LinkIcon, Link as LinkIcon,
Package, Package,
PackageSearch,
Pencil, Pencil,
RefreshCw, RefreshCw,
Repeat2, Repeat2,
@@ -23,9 +24,14 @@ import {
Warehouse, Warehouse,
} from 'lucide-react'; } from 'lucide-react';
import { buildConsumptionReferencePath } from '../catalogLinks'; import { buildConsumptionReferencePath } from '../catalogLinks';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls'; import PaginationControls from '../components/PaginationControls';
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchSupplySummary } from '../dataService'; import ProductTypeBadge from '../components/ProductTypeBadge';
import type { SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types'; import { classifyCutFamily } from '../analytics/cutting';
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchCuttingSettings, fetchProductAnalytics, fetchSupplySummary } from '../dataService';
import { parseProductName } from '../productParsing';
import { resolveProductType, type ProductTypeKey } from '../productClassification';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types';
type InventoryTab = 'dashboard' | 'balance' | 'receipts' | 'inventory' | 'movements'; type InventoryTab = 'dashboard' | 'balance' | 'receipts' | 'inventory' | 'movements';
type ReceiptView = 'new' | 'pending' | 'history'; type ReceiptView = 'new' | 'pending' | 'history';
@@ -108,6 +114,38 @@ const receiptCategories: Array<{ name: string; icon: typeof Package }> = [
{ name: 'Outro material', icon: ClipboardList }, { name: 'Outro material', icon: ClipboardList },
]; ];
type DemandQueue = 'apparel' | 'inputs' | 'review' | 'dead';
type DemandRow = ProductAnalyticsItem & {
productType: ProductTypeKey;
color: string;
size: string;
dailySales: number;
daysOfCover: number | null;
targetDemand: number;
suggestedUnits: number;
missingData: string[];
queue: DemandQueue;
};
const demandQueueLabels: Record<DemandQueue, string> = {
apparel: 'Cortar / Repor',
inputs: 'Comprar insumos',
review: 'Revisar dados',
dead: 'Estoque parado',
};
const demandQueueDescriptions: Record<DemandQueue, string> = {
apparel: 'Produtos acabados com venda e cobertura baixa. Ainda é sugestão por unidade, não kg de malha.',
inputs: 'Embalagens, matérias-primas e insumos DTF com cobertura baixa pelo próprio estoque.',
review: 'SKUs vendidos que precisam de correção de tipo, cor, tamanho ou família antes de planejar melhor.',
dead: 'Itens com estoque e pouca ou nenhuma venda no período.',
};
const directPurchaseTypes = new Set<ProductTypeKey>(['packaging', 'dtf_input', 'raw_material', 'machine_part', 'finished_accessory']);
const planningReviewTypes = new Set<ProductTypeKey>(['unknown', 'kit_bundle']);
const Header = ({ title, subtitle, backTo }: { title: string; subtitle: string; backTo?: string }) => ( const Header = ({ title, subtitle, backTo }: { title: string; subtitle: string; backTo?: string }) => (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{backTo && ( {backTo && (
@@ -154,6 +192,7 @@ const SuppliesHub = () => (
<div className={pageClassName}> <div className={pageClassName}>
<Header title="Suprimentos" subtitle="Corte, estoque, malha e compras em um fluxo operacional." /> <Header title="Suprimentos" subtitle="Corte, estoque, malha e compras em um fluxo operacional." />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<ModuleCard title="Planejamento por Dados Atuais" description="Filas de reposição, compra, revisão e estoque parado usando vendas, estoque e cobertura do Tiny." icon={PackageSearch} to="/supplies/current-planning" />
<ModuleCard title="Planejamento de Corte" description="Ruptura por cor e tamanho, montagem do corte e prioridade por cobertura." icon={Scissors} to="/cutting" /> <ModuleCard title="Planejamento de Corte" description="Ruptura por cor e tamanho, montagem do corte e prioridade por cobertura." icon={Scissors} to="/cutting" />
<ModuleCard title="Controle de Estoque" description="Saldo, lotes, recebimentos, inventário e movimentações." icon={Package} to="/supplies/inventory" /> <ModuleCard title="Controle de Estoque" description="Saldo, lotes, recebimentos, inventário e movimentações." icon={Package} to="/supplies/inventory" />
<ModuleCard title="Ordens de Produção" description="Ordens geradas pelo corte e acompanhamento de produção." icon={ClipboardList} to="/production-orders" /> <ModuleCard title="Ordens de Produção" description="Ordens geradas pelo corte e acompanhamento de produção." icon={ClipboardList} to="/production-orders" />
@@ -163,6 +202,369 @@ const SuppliesHub = () => (
</div> </div>
); );
const getRangeDays = (range: DateRange) => {
const start = new Date(range.start);
const end = new Date(range.end);
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1);
};
const formatDays = (value: number | null) => {
if (value === null) return '-';
if (value > 999) return '999+ dias';
return `${formatNumber(value, value < 10 ? 1 : 0)} dias`;
};
const buildDemandRow = (
product: ProductAnalyticsItem,
settings: CuttingSettings,
rangeDays: number,
targetCoverageDays: number
): DemandRow => {
const override = settings.productOverrides[product.id];
const metadata = parseProductName(product.name);
const productType = resolveProductType(product.name, override);
const color = override?.color || metadata.color;
const size = (override?.size || metadata.size).toUpperCase();
const dailySales = product.quantitySold / rangeDays;
const daysOfCover = dailySales > 0 ? product.stock / dailySales : null;
const targetDemand = dailySales * targetCoverageDays;
const suggestedUnits = Math.max(0, Math.ceil(targetDemand - product.stock));
const missingData: string[] = [];
if (planningReviewTypes.has(productType)) missingData.push('tipo');
if (productType === 'finished_apparel') {
if (!color) missingData.push('cor');
if (!size) missingData.push('tamanho');
if ((override?.familyKey || classifyCutFamily(metadata.baseName).key) === 'OUTROS') missingData.push('família');
}
let queue: DemandQueue = 'review';
if (product.stock > 0 && (dailySales === 0 || (daysOfCover !== null && daysOfCover > 120))) {
queue = 'dead';
} else if (missingData.length) {
queue = 'review';
} else if (productType === 'finished_apparel') {
queue = 'apparel';
} else if (directPurchaseTypes.has(productType)) {
queue = 'inputs';
}
return {
...product,
productType,
color,
size,
dailySales,
daysOfCover,
targetDemand,
suggestedUnits,
missingData,
queue,
};
};
const DemandPlanningScreen = () => {
const { dateRange, setDateRange } = useOutletContext<{
dateRange: DateRange,
setDateRange: (range: DateRange) => void
}>();
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
const [settings, setSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
const [isLoading, setIsLoading] = useState(true);
const [queue, setQueue] = useState<DemandQueue>('apparel');
const [search, setSearch] = useState('');
const [targetCoverageDays, setTargetCoverageDays] = useState(30);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
const [productData, planningSettings] = await Promise.all([
fetchProductAnalytics(dateRange),
fetchCuttingSettings(),
]);
if (isMounted) {
setProducts(productData);
setSettings(planningSettings);
setIsLoading(false);
}
};
void load();
return () => {
isMounted = false;
};
}, [dateRange]);
const rows = useMemo(() => {
const rangeDays = getRangeDays(dateRange);
return products.map(product => buildDemandRow(product, settings, rangeDays, targetCoverageDays));
}, [dateRange, products, settings, targetCoverageDays]);
const queueRows = useMemo(() => {
const normalizedSearch = normalizeSearch(search);
return rows
.filter(row => row.queue === queue)
.filter(row => {
if (queue === 'dead') return row.stock > 0;
if (queue === 'review') return row.quantitySold > 0 || row.stock > 0;
return row.dailySales > 0 && (row.suggestedUnits > 0 || (row.daysOfCover !== null && row.daysOfCover <= targetCoverageDays));
})
.filter(row => (
!normalizedSearch || normalizeSearch(`${row.id} ${row.name} ${row.color} ${row.size} ${row.missingData.join(' ')}`).includes(normalizedSearch)
))
.sort((a, b) => {
if (queue === 'dead') {
return (b.stock * b.lastPrice) - (a.stock * a.lastPrice);
}
if (queue === 'review') {
if (b.quantitySold !== a.quantitySold) return b.quantitySold - a.quantitySold;
return b.revenue - a.revenue;
}
if (b.suggestedUnits !== a.suggestedUnits) return b.suggestedUnits - a.suggestedUnits;
return b.dailySales - a.dailySales;
});
}, [queue, rows, search, targetCoverageDays]);
const queueStats = useMemo(() => {
return (Object.keys(demandQueueLabels) as DemandQueue[]).reduce<Record<DemandQueue, number>>((acc, item) => {
acc[item] = rows.filter(row => {
if (row.queue !== item) return false;
if (item === 'dead') return row.stock > 0;
if (item === 'review') return row.quantitySold > 0 || row.stock > 0;
return row.dailySales > 0 && (row.suggestedUnits > 0 || (row.daysOfCover !== null && row.daysOfCover <= targetCoverageDays));
}).length;
return acc;
}, { apparel: 0, inputs: 0, review: 0, dead: 0 });
}, [rows, targetCoverageDays]);
const totalPages = Math.ceil(queueRows.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedRows = queueRows.slice(startIndex, startIndex + itemsPerPage);
const suggestedTotal = queueRows.reduce((total, row) => total + (queue === 'dead' ? row.stock * row.lastPrice : row.suggestedUnits), 0);
return (
<div className={pageClassName}>
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
<Header title="Planejamento por Dados Atuais" subtitle="Filas operacionais usando vendas, estoque e cobertura já disponíveis no Tiny/prod graphs." backTo="/supplies" />
<DateRangePicker
dateRange={dateRange}
onChange={(range) => {
setDateRange(range);
setCurrentPage(1);
}}
/>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
{(Object.keys(demandQueueLabels) as DemandQueue[]).map(item => (
<button
key={item}
type="button"
onClick={() => {
setQueue(item);
setCurrentPage(1);
}}
className={`rounded-2xl border p-4 text-left transition-colors cursor-pointer ${
queue === item ? 'border-brand-primary/45 bg-brand-primary/10' : 'border-dark-border bg-dark-card hover:border-brand-primary/35'
}`}
>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{demandQueueLabels[item]}</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(queueStats[item], 0)}</p>
<p className="mt-1 line-clamp-2 text-xs font-semibold text-dark-muted">{demandQueueDescriptions[item]}</p>
</button>
))}
</div>
<div className={`${panelClassName} p-5`}>
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<h2 className="text-base font-bold text-dark-text">{demandQueueLabels[queue]}</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">{demandQueueDescriptions[queue]}</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<label className="relative min-w-72">
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
<input
value={search}
onChange={(event) => {
setSearch(event.target.value);
setCurrentPage(1);
}}
className={`${inputClassName} pl-9`}
placeholder="Buscar SKU, produto, cor..."
/>
</label>
<select
value={targetCoverageDays}
onChange={(event) => {
setTargetCoverageDays(Number(event.target.value));
setCurrentPage(1);
}}
className={inputClassName}
title="Cobertura alvo"
>
{[15, 30, 45, 60].map(days => <option key={days} value={days}>{days} dias</option>)}
</select>
<button
type="button"
onClick={() => exportCsv('planejamento-dados-atuais.csv', queueRows.map(row => ({
fila: demandQueueLabels[queue],
sku: row.id,
produto: row.name,
tipo: row.productType,
cor: row.color,
tamanho: row.size,
vendido: row.quantitySold,
estoque: row.stock,
media_diaria: row.dailySales.toFixed(2).replace('.', ','),
cobertura_dias: row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','),
sugestao_unidades: row.suggestedUnits,
pendencias: row.missingData.join(' | '),
})))}
className={buttonClassName}
>
<Download className="h-4 w-4" />
CSV
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="rounded-xl border border-dark-border bg-dark-input/35 p-4">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Itens na fila</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(queueRows.length, 0)}</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-input/35 p-4">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{queue === 'dead' ? 'Valor em estoque' : 'Unidades sugeridas'}</p>
<p className="mt-2 text-2xl font-bold text-dark-text">
{queue === 'dead'
? new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(suggestedTotal)
: `${formatNumber(suggestedTotal, 0)} un.`}
</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-input/35 p-4">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Cobertura alvo</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{targetCoverageDays} dias</p>
</div>
</div>
{isLoading ? (
<div className={emptyStateClassName}>
<PackageSearch className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Carregando dados de produtos...</h3>
</div>
) : queueRows.length ? (
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
<div className="overflow-x-auto">
<table className="w-full min-w-[1080px] table-fixed border-collapse">
<colgroup>
<col className="w-[120px]" />
<col />
<col className="w-[140px]" />
<col className="w-[120px]" />
<col className="w-[120px]" />
<col className="w-[120px]" />
<col className="w-[130px]" />
<col className="w-[100px]" />
</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">SKU</th>
<th scope="col" className="px-4 py-3 text-left">Produto</th>
<th scope="col" className="px-4 py-3 text-left">Tipo</th>
<th scope="col" className="px-4 py-3 text-right">Média/dia</th>
<th scope="col" className="px-4 py-3 text-right">Estoque</th>
<th scope="col" className="px-4 py-3 text-right">Cobertura</th>
<th scope="col" className="px-4 py-3 text-right">{queue === 'dead' ? 'Valor estoque' : 'Sugestão'}</th>
<th scope="col" className="px-4 py-3 text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border bg-dark-card">
{paginatedRows.map(row => (
<tr key={row.id}>
<td className="px-4 py-3 font-mono text-[11px] text-dark-muted">#{row.id}</td>
<td className="px-4 py-3">
<p className="truncate text-sm font-bold text-dark-text" title={row.name}>{row.name}</p>
<p className="mt-1 truncate text-xs font-semibold text-dark-muted">
Cor: {row.color || '-'} · Tam.: {row.size || '-'}
{row.missingData.length ? ` · Falta: ${row.missingData.join(', ')}` : ''}
</p>
</td>
<td className="px-4 py-3"><ProductTypeBadge type={row.productType} /></td>
<td className="px-4 py-3 text-right text-sm font-bold text-dark-text">{formatNumber(row.dailySales, 2)}</td>
<td className="px-4 py-3 text-right text-sm font-bold text-dark-text">{formatNumber(row.stock, 0)} un.</td>
<td className="px-4 py-3 text-right text-sm font-bold text-dark-text">{formatDays(row.daysOfCover)}</td>
<td className={`px-4 py-3 text-right text-sm font-bold ${queue === 'dead' ? 'text-amber-300' : row.suggestedUnits > 0 ? 'text-amber-300' : 'text-emerald-300'}`}>
{queue === 'dead'
? new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(row.stock * row.lastPrice)
: `${formatNumber(row.suggestedUnits, 0)} un.`}
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-2">
{queue === 'review' && (
<RouterLink
to="/planning-issues"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
title="Abrir dados pendentes"
aria-label="Abrir dados pendentes"
>
<Pencil className="h-3.5 w-3.5" />
</RouterLink>
)}
<RouterLink
to={`/products/${row.id}`}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80"
title={`Ver SKU ${row.id}`}
aria-label={`Ver SKU ${row.id}`}
>
<ArrowRight className="h-3.5 w-3.5" />
</RouterLink>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<PaginationControls
totalItems={queueRows.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="SKUs"
pageSizeLabel="itens por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, queueRows.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1);
}}
className="border-t border-dark-border px-4 py-3"
/>
</div>
) : (
<div className={emptyStateClassName}>
<PackageSearch className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Nada nesta fila</h3>
<p className="text-sm font-semibold text-dark-muted">Ajuste a busca, período ou cobertura alvo.</p>
</div>
)}
</div>
</div>
);
};
const StatGrid = ({ summary }: { summary: SupplySummary }) => { const StatGrid = ({ summary }: { summary: SupplySummary }) => {
const stats = [ const stats = [
{ label: 'Total em estoque', value: `${formatNumber(summary.stats.totalQuantityKg)} kg` }, { label: 'Total em estoque', value: `${formatNumber(summary.stats.totalQuantityKg)} kg` },
@@ -1364,6 +1766,7 @@ const Supplies = () => {
const { section } = useParams<{ section?: string }>(); const { section } = useParams<{ section?: string }>();
if (!section) return <SuppliesHub />; if (!section) return <SuppliesHub />;
if (section === 'current-planning') return <DemandPlanningScreen />;
if (section === 'inventory') return <InventoryScreen />; if (section === 'inventory') return <InventoryScreen />;
if (section === 'fabric-planning') return <FabricPlanningScreen />; if (section === 'fabric-planning') return <FabricPlanningScreen />;
if (section === 'purchase-needs') return <PurchaseNeedsScreen />; if (section === 'purchase-needs') return <PurchaseNeedsScreen />;