Show Tiny stock in inventory control
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m14s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m14s
This commit is contained in:
@@ -28,13 +28,13 @@ import DateRangePicker from '../components/DateRangePicker';
|
|||||||
import PaginationControls from '../components/PaginationControls';
|
import PaginationControls from '../components/PaginationControls';
|
||||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||||
import { classifyCutFamily } from '../analytics/cutting';
|
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 { parseProductName } from '../productParsing';
|
||||||
import { resolveProductType, type ProductTypeKey } from '../productClassification';
|
import { resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||||
import { getPlanningStock } from '../planningStock';
|
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';
|
type ReceiptView = 'new' | 'pending' | 'history';
|
||||||
|
|
||||||
const pageClassName = 'flex w-full flex-col gap-6';
|
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 }> = [
|
const inventoryTabs: Array<{ id: InventoryTab; name: string; icon: typeof BarChart3 }> = [
|
||||||
{ id: 'dashboard', name: 'Dashboard', icon: Warehouse },
|
{ id: 'dashboard', name: 'Dashboard', icon: Warehouse },
|
||||||
|
{ id: 'tiny_stock', name: 'Estoque Tiny', icon: PackageSearch },
|
||||||
{ id: 'balance', name: 'Saldo', icon: BarChart3 },
|
{ id: 'balance', name: 'Saldo', icon: BarChart3 },
|
||||||
{ id: 'receipts', name: 'Recebimentos', icon: Truck },
|
{ id: 'receipts', name: 'Recebimentos', icon: Truck },
|
||||||
{ id: 'inventory', name: 'Inventário', icon: ClipboardCheck },
|
{ 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 = ({
|
const InventoryCountTab = ({
|
||||||
lots,
|
lots,
|
||||||
onAdjust,
|
onAdjust,
|
||||||
@@ -1233,24 +1368,46 @@ const MovementsTab = ({
|
|||||||
const InventoryScreen = () => {
|
const InventoryScreen = () => {
|
||||||
const [activeTab, setActiveTab] = useState<InventoryTab>('dashboard');
|
const [activeTab, setActiveTab] = useState<InventoryTab>('dashboard');
|
||||||
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
||||||
|
const [tinyStock, setTinyStock] = useState<StockData[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isBusy, setIsBusy] = useState(false);
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
const loadInventoryData = async () => {
|
||||||
|
setErrorMessage('');
|
||||||
|
const [nextSummary, nextTinyStock] = await Promise.all([
|
||||||
|
fetchSupplySummary(),
|
||||||
|
fetchStock(),
|
||||||
|
]);
|
||||||
|
setSummary(nextSummary);
|
||||||
|
setTinyStock(nextTinyStock);
|
||||||
|
};
|
||||||
|
|
||||||
const loadSummary = async () => {
|
const loadSummary = async () => {
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
const nextSummary = await fetchSupplySummary();
|
const nextSummary = await fetchSupplySummary();
|
||||||
setSummary(nextSummary);
|
setSummary(nextSummary);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadTinyStock = async () => {
|
||||||
|
setErrorMessage('');
|
||||||
|
setTinyStock(await fetchStock());
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const nextSummary = await fetchSupplySummary();
|
const [nextSummary, nextTinyStock] = await Promise.all([
|
||||||
if (isMounted) setSummary(nextSummary);
|
fetchSupplySummary(),
|
||||||
|
fetchStock(),
|
||||||
|
]);
|
||||||
|
if (isMounted) {
|
||||||
|
setSummary(nextSummary);
|
||||||
|
setTinyStock(nextTinyStock);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (isMounted) setIsLoading(false);
|
if (isMounted) setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -1268,7 +1425,7 @@ const InventoryScreen = () => {
|
|||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
try {
|
try {
|
||||||
await action();
|
await action();
|
||||||
await loadSummary();
|
await loadInventoryData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorMessage(error instanceof Error ? error.message : 'Não foi possível atualizar suprimentos.');
|
setErrorMessage(error instanceof Error ? error.message : 'Não foi possível atualizar suprimentos.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1304,6 +1461,7 @@ const InventoryScreen = () => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{activeTab === 'dashboard' && <InventoryDashboard summary={summary} onNewReceipt={() => setActiveTab('receipts')} />}
|
{activeTab === 'dashboard' && <InventoryDashboard summary={summary} onNewReceipt={() => setActiveTab('receipts')} />}
|
||||||
|
{activeTab === 'tiny_stock' && <TinyStockTab stock={tinyStock} onRefresh={loadTinyStock} />}
|
||||||
{activeTab === 'balance' && <BalanceTab summary={summary} onRefresh={loadSummary} />}
|
{activeTab === 'balance' && <BalanceTab summary={summary} onRefresh={loadSummary} />}
|
||||||
{activeTab === 'receipts' && (
|
{activeTab === 'receipts' && (
|
||||||
<ReceiptsTab
|
<ReceiptsTab
|
||||||
|
|||||||
Reference in New Issue
Block a user