Compare commits

..

2 Commits

Author SHA1 Message Date
Cauê Faleiros
bc74a1089b Show Tiny stock in inventory control
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m14s
2026-07-21 10:46:24 -03:00
Cauê Faleiros
f992517891 Reposition fabric planning form 2026-07-21 10:11:23 -03:00

View File

@@ -28,13 +28,13 @@ import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import ProductTypeBadge from '../components/ProductTypeBadge';
import { classifyCutFamily } from '../analytics/cutting';
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchCuttingSettings, fetchProductAnalytics, fetchSupplySummary } from '../dataService';
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchCuttingSettings, fetchProductAnalytics, fetchStock, fetchSupplySummary } from '../dataService';
import { parseProductName } from '../productParsing';
import { resolveProductType, type ProductTypeKey } from '../productClassification';
import { getPlanningStock } from '../planningStock';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, StockData, SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types';
type InventoryTab = 'dashboard' | 'balance' | 'receipts' | 'inventory' | 'movements';
type InventoryTab = 'dashboard' | 'tiny_stock' | 'balance' | 'receipts' | 'inventory' | 'movements';
type ReceiptView = 'new' | 'pending' | 'history';
const pageClassName = 'flex w-full flex-col gap-6';
@@ -100,6 +100,7 @@ const exportCsv = (filename: string, rows: Array<Record<string, string | number
const inventoryTabs: Array<{ id: InventoryTab; name: string; icon: typeof BarChart3 }> = [
{ id: 'dashboard', name: 'Dashboard', icon: Warehouse },
{ id: 'tiny_stock', name: 'Estoque Tiny', icon: PackageSearch },
{ id: 'balance', name: 'Saldo', icon: BarChart3 },
{ id: 'receipts', name: 'Recebimentos', icon: Truck },
{ id: 'inventory', name: 'Inventário', icon: ClipboardCheck },
@@ -958,6 +959,140 @@ const ReceiptsTab = ({
);
};
const TinyStockTab = ({
stock,
onRefresh,
}: {
stock: StockData[];
onRefresh: () => void;
}) => {
const [search, setSearch] = useState('');
const [stockFilter, setStockFilter] = useState('all');
const normalizedSearch = normalizeSearch(search);
const totalBalance = stock.reduce((total, item) => total + Number(item.saldo || 0), 0);
const updatedItems = stock.filter(item => Number(item.delta_estoque || 0) !== 0).length;
const latestUpdate = stock
.map(item => item.updated_at ? new Date(item.updated_at).getTime() : 0)
.filter(Boolean)
.sort((a, b) => b - a)[0] || null;
const visibleStock = stock.filter(item => {
const balance = Number(item.saldo || 0);
const delta = Number(item.delta_estoque || 0);
const matchesSearch = !normalizedSearch || normalizeSearch(`${item.produto_id} ${item.nome}`).includes(normalizedSearch);
const matchesFilter =
stockFilter === 'all' ||
(stockFilter === 'positive' && balance > 0) ||
(stockFilter === 'empty' && balance <= 0) ||
(stockFilter === 'changed' && delta !== 0);
return matchesSearch && matchesFilter;
}).sort((a, b) => Number(b.saldo || 0) - Number(a.saldo || 0));
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">SKUs Tiny</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(stock.length, 0)}</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saldo total</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(totalBalance, 0)} un.</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Com delta</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(updatedItems, 0)}</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Última atualização</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{latestUpdate ? formatDateTime(new Date(latestUpdate).toISOString()) : '-'}</p>
</div>
</div>
<div className={`${panelClassName} p-4`}>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-base font-bold text-dark-text">Estoque vindo do Tiny</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Saldo de produtos sincronizado pelo fluxo Tiny para Graphs.</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<button
type="button"
onClick={() => exportCsv('estoque-tiny.csv', visibleStock.map(item => ({
produto_id: item.produto_id,
nome: item.nome,
saldo: item.saldo,
delta_estoque: item.delta_estoque,
atualizado_em: item.updated_at || '',
})))}
className={buttonClassName}
>
<Download className="h-4 w-4" /> CSV
</button>
<button type="button" onClick={onRefresh} className={buttonClassName}>
<RefreshCw className="h-4 w-4" /> Atualizar
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 lg:grid-cols-[1fr_220px]">
<label className="relative">
<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)}
className={`${inputClassName} pl-9`}
placeholder="Buscar por SKU ou produto..."
/>
</label>
<select value={stockFilter} onChange={(event) => setStockFilter(event.target.value)} className={inputClassName}>
<option value="all">Todos os saldos</option>
<option value="positive">Com saldo</option>
<option value="empty">Sem saldo</option>
<option value="changed">Com delta</option>
</select>
</div>
{visibleStock.length ? (
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
<div className="hidden grid-cols-[140px_1fr_120px_120px_160px] border-b border-dark-border bg-dark-input/40 px-4 py-3 text-xs font-bold uppercase tracking-widest text-dark-muted md:grid">
<span>SKU Tiny</span>
<span>Produto</span>
<span className="text-right">Saldo</span>
<span className="text-right">Delta</span>
<span className="text-right">Atualizado</span>
</div>
<div className="divide-y divide-dark-border">
{visibleStock.map(item => {
const delta = Number(item.delta_estoque || 0);
return (
<div key={item.produto_id} className="grid grid-cols-1 gap-2 bg-dark-card px-4 py-3 md:grid-cols-[140px_1fr_120px_120px_160px] md:items-center">
<p className="font-mono text-xs font-bold text-dark-muted">#{item.produto_id}</p>
<p className="text-sm font-bold text-dark-text">{item.nome}</p>
<p className="text-sm font-bold text-dark-text md:text-right">{formatNumber(Number(item.saldo || 0), 0)} un.</p>
<p className={`text-sm font-bold md:text-right ${delta > 0 ? 'text-emerald-300' : delta < 0 ? 'text-red-300' : 'text-dark-muted'}`}>
{delta > 0 ? '+' : ''}{formatNumber(delta, 0)}
</p>
<p className="text-xs font-semibold text-dark-muted md:text-right">{formatDateTime(item.updated_at || null)}</p>
</div>
);
})}
</div>
</div>
) : (
<div className={emptyStateClassName}>
<PackageSearch className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">{stock.length ? 'Nenhum SKU encontrado' : 'Nenhum estoque Tiny sincronizado'}</h3>
<p className="text-sm font-semibold text-dark-muted">
{stock.length ? 'Ajuste a busca ou o filtro.' : 'Quando o fluxo Tiny postar em /api/stock, os saldos aparecem aqui.'}
</p>
</div>
)}
</div>
</div>
);
};
const InventoryCountTab = ({
lots,
onAdjust,
@@ -1233,24 +1368,46 @@ const MovementsTab = ({
const InventoryScreen = () => {
const [activeTab, setActiveTab] = useState<InventoryTab>('dashboard');
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
const [tinyStock, setTinyStock] = useState<StockData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isBusy, setIsBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const loadInventoryData = async () => {
setErrorMessage('');
const [nextSummary, nextTinyStock] = await Promise.all([
fetchSupplySummary(),
fetchStock(),
]);
setSummary(nextSummary);
setTinyStock(nextTinyStock);
};
const loadSummary = async () => {
setErrorMessage('');
const nextSummary = await fetchSupplySummary();
setSummary(nextSummary);
};
const loadTinyStock = async () => {
setErrorMessage('');
setTinyStock(await fetchStock());
};
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
try {
const nextSummary = await fetchSupplySummary();
if (isMounted) setSummary(nextSummary);
const [nextSummary, nextTinyStock] = await Promise.all([
fetchSupplySummary(),
fetchStock(),
]);
if (isMounted) {
setSummary(nextSummary);
setTinyStock(nextTinyStock);
}
} finally {
if (isMounted) setIsLoading(false);
}
@@ -1268,7 +1425,7 @@ const InventoryScreen = () => {
setErrorMessage('');
try {
await action();
await loadSummary();
await loadInventoryData();
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Não foi possível atualizar suprimentos.');
} finally {
@@ -1304,6 +1461,7 @@ const InventoryScreen = () => {
) : (
<>
{activeTab === 'dashboard' && <InventoryDashboard summary={summary} onNewReceipt={() => setActiveTab('receipts')} />}
{activeTab === 'tiny_stock' && <TinyStockTab stock={tinyStock} onRefresh={loadTinyStock} />}
{activeTab === 'balance' && <BalanceTab summary={summary} onRefresh={loadSummary} />}
{activeTab === 'receipts' && (
<ReceiptsTab
@@ -1431,22 +1589,8 @@ const FabricPlanningScreen = () => {
{errorMessage}
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div 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">Planos ativos</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{plans.length}</p>
</div>
<div 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">Kg planejado</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(totalKg)} kg</p>
</div>
<div 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">Críticos</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{plans.filter(plan => plan.priority === 'Crítico').length}</p>
</div>
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[420px_1fr]">
<form onSubmit={handleSubmit} className={`${panelClassName} p-5`}>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[420px_1fr] xl:items-start">
<form onSubmit={handleSubmit} className={`${panelClassName} p-5 xl:sticky xl:top-6`}>
<h2 className="text-base font-bold text-dark-text">Novo plano de malha</h2>
<div className="mt-4 grid grid-cols-1 gap-3">
<label className="text-xs font-bold text-dark-muted">
@@ -1482,6 +1626,22 @@ const FabricPlanningScreen = () => {
</div>
</form>
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div 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">Planos ativos</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{plans.length}</p>
</div>
<div 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">Kg planejado</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(totalKg)} kg</p>
</div>
<div 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">Críticos</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{plans.filter(plan => plan.priority === 'Crítico').length}</p>
</div>
</div>
<div className={`${panelClassName} overflow-hidden`}>
<div className="border-b border-dark-border p-5">
<h2 className="text-base font-bold text-dark-text">Planos de malha</h2>
@@ -1526,6 +1686,7 @@ const FabricPlanningScreen = () => {
</div>
</div>
</div>
</div>
);
};