// Adapter: status DB → UI const REC_STATUS_DB_TO_UI = { pendente: 'Pendente', aprovado: 'Aprovado', recusado: 'Recusado', }; const toUiReceiving = (row) => ({ id: row.id, code: row.code, status: REC_STATUS_DB_TO_UI[row.status] || 'Pendente', status_db: row.status, divergence_count: row.divergence_count || 0, notes: row.notes, at: row.received_at ? new Date(row.received_at).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' }) : '—', approved_at: row.approved_at, order: row.order?.code || '—', order_id: row.order?.id, supplier: row.order?.supplier?.name || '—', itemsCount: row._itemsCount || 0, }); function ScreenStock({ setRoute }) { const { CATEGORIES, BRL } = window.SCP; const [products, setProducts] = useState([]); const [receivings, setReceivings] = useState([]); const [loading, setLoading] = useState(true); const [loadErr, setLoadErr] = useState(null); const [showNewRec, setShowNewRec] = useState(false); const [activeRec, setActiveRec] = useState(null); // recebimento "Conferência em andamento" const [activeRecItems, setActiveRecItems] = useState([]); // itens da conferência atual const [approving, setApproving] = useState(false); useEffect(() => { let mounted = true; (async () => { try { const [prods, recs] = await Promise.all([ window.scpDb.products.list(), window.scpDb.receivings.list(), ]); if (!mounted) return; // adapta para UI shape (com .min, .lastPrice etc.) const ui = prods.map(p => ({ ...p, min: p.min_stock, lastPrice: Number(p.last_price ?? 0), supplier: p.preferred_supplier?.name || '—', })); setProducts(ui); setReceivings(recs.map(toUiReceiving)); } catch (e) { if (mounted) setLoadErr(e.message || 'Falha ao carregar dados'); } finally { if (mounted) setLoading(false); } })(); return () => { mounted = false; }; }, []); // Carrega o primeiro recebimento pendente como "Conferência em andamento" useEffect(() => { const pendente = receivings.find(r => r.status === 'Pendente'); if (!pendente) { setActiveRec(null); setActiveRecItems([]); return; } (async () => { try { const full = await window.scpDb.receivings.getById(pendente.id); setActiveRec(full); setActiveRecItems(full.items || []); } catch (e) { /* ignore */ } })(); }, [receivings]); const low = useMemo( () => products.filter(p => Number(p.stock) < Number(p.min)).sort((a, b) => (a.stock / Math.max(1, a.min)) - (b.stock / Math.max(1, b.min))), [products] ); const stockValue = useMemo( () => products.reduce((s, p) => s + Number(p.stock) * Number(p.lastPrice), 0), [products] ); const kpis = useMemo(() => { const pendentes = receivings.filter(r => r.status === 'Pendente').length; const divergencias = receivings.filter(r => r.status === 'Pendente' && r.divergence_count > 0).length; return { pendentes, divergencias, totalSKU: products.length, }; }, [receivings, products]); const handleApprove = async () => { if (!activeRec) return; setApproving(true); try { await window.scpDb.receivings.approve(activeRec.id, { fallbackToOrdered: true }); window.scpToast('Conferência aprovada', { kind: 'emerald', sub: `${activeRec.code} · entrada lançada no estoque`, }); // recarrega receivings e produtos const [prods, recs] = await Promise.all([ window.scpDb.products.list(), window.scpDb.receivings.list(), ]); setProducts(prods.map(p => ({ ...p, min: p.min_stock, lastPrice: Number(p.last_price ?? 0), supplier: p.preferred_supplier?.name || '—' }))); setReceivings(recs.map(toUiReceiving)); } catch (e) { window.scpToast('Erro ao aprovar', { kind: 'coral', sub: e.message }); } finally { setApproving(false); } }; const handleReject = async () => { if (!activeRec) return; if (!confirm(`Recusar lote ${activeRec.code}? Não entra no estoque.`)) return; try { await window.scpDb.receivings.reject(activeRec.id, 'Lote recusado pelo conferente'); window.scpToast('Lote recusado', { kind: 'coral', sub: `${activeRec.code} · devolução agendada` }); const recs = await window.scpDb.receivings.list(); setReceivings(recs.map(toUiReceiving)); } catch (e) { window.scpToast('Erro ao recusar', { kind: 'coral', sub: e.message }); } }; const handleAutoQuote = () => { window.scpToast('Cotação automática iniciada', { kind: 'emerald', sub: `${low.length} produtos abaixo do mínimo` }); setTimeout(() => setRoute && setRoute('newQuotation'), 600); }; const refreshReceivings = async () => { const recs = await window.scpDb.receivings.list(); setReceivings(recs.map(toUiReceiving)); }; return (
}/> 0 ? 'Sugerir cotação automática' : 'Tudo em dia'} delta={low.length > 0 ? 'reposição' : 'ok'} deltaTone={low.length > 0 ? 'amber' : 'emerald'} icon={}/> 0 ? `${kpis.divergencias} com divergência` : 'Sem divergências'} icon={}/> { const d = new Date(r.approved_at || 0); return d.getMonth() === new Date().getMonth(); }).length)} sub="Aprovados/conferidos" icon={}/>
{loadErr && ( Falha ao carregar estoque:{' '} {loadErr} )}
} onClick={() => setShowNewRec(true)}>Novo Recebimento} />
{loading && ( )} {!loading && receivings.length === 0 && ( )} {!loading && receivings.map(r => ( ))}
Recebimento Pedido Fornecedor Itens Divergências Status Quando
Carregando recebimentos…
Nenhum recebimento registrado. Crie um quando o pedido chegar.
{r.code} {r.order} {r.supplier} {r.divergence_count} {r.divergence_count === 0 ? Sem divergências : {r.divergence_count} {r.divergence_count === 1 ? 'item' : 'itens'}} {r.status} {r.at} } size="sm"/>
{/* Receiving in-progress card */} {!activeRec ? (
Nenhuma conferência aberta. Crie um novo recebimento quando um pedido chegar.
) : ( <>
{activeRecItems.length === 0 && (
Sem itens no recebimento.
)} {activeRecItems.map((it, i) => { const ordered = Number(it.qty_ordered); const received = Number(it.qty_received); const ok = it.ok || (received === ordered && received > 0); return (
{ok && }
Item · ID {it.product_id?.slice(0, 8)} Pedido: {ordered} · Recebido: {received} {it.divergence && ⚠ {it.divergence}}
); })}
{activeRec.status === 'aprovado' || activeRec.status === 'recusado' ? (
Conferência finalizada
) : (
} onClick={handleApprove} disabled={approving}> {approving ? 'Aprovando…' : 'Aprovar com ressalvas'} Recusar lote
)} )}
} onClick={handleAutoQuote}>Abrir cotação automática}/>
{!loading && low.length === 0 && ( )} {!loading && low.map(p => { const c = CATEGORIES.find(c => c.key === p.cat); const r = p.min > 0 ? p.stock / p.min : 0; const need = Math.max(0, p.min * 2 - p.stock); return ( ); })}
Produto Categoria Estoque Mínimo Nível Sugestão de compra Melhor fornecedor
Nenhum produto abaixo do mínimo. Estoque saudável.
{p.name}
{p.code}
{c.label} {p.stock} {p.unit} {p.min}
{Math.round(r * 100)}%
{need} {p.unit} {p.supplier}
{showNewRec && setShowNewRec(false)} onCreated={refreshReceivings} />}
); } function NewReceivingModal({ onClose, onCreated }) { const [orderId, setOrderId] = useState(''); const [note, setNote] = useState(''); const [orders, setOrders] = useState([]); const [saving, setSaving] = useState(false); useEffect(() => { let mounted = true; (async () => { const rows = await window.scpDb.orders.list(); // só pedidos em trânsito/confirmado/emitido (que faz sentido conferir) const filtered = rows.filter(o => ['em_transito', 'confirmado', 'emitido'].includes(o.status)); if (mounted) setOrders(filtered); })(); return () => { mounted = false; }; }, []); const selected = orders.find(o => o.id === orderId); const canSubmit = !!orderId && !saving; const submit = async () => { if (!canSubmit) return; setSaving(true); try { const created = await window.scpDb.receivings.create({ order_id: orderId, notes: note }); window.scpToast('Recebimento registrado', { kind: 'emerald', sub: `${created.code} · aguardando conferência` }); onCreated && (await onCreated()); onClose(); } catch (e) { window.scpToast('Erro ao criar recebimento', { kind: 'coral', sub: e.message }); } finally { setSaving(false); } }; return ReactDOM.createPortal((
e.stopPropagation()}>

Novo Recebimento

} size="sm" onClick={onClose}/>
Pedido de compra * {orders.length === 0 && Nenhum pedido aguardando conferência.}
{selected && (
{selected.supplier?.name}
{selected.itemsCount} itens · ETA {selected.eta ? new Date(selected.eta).toLocaleDateString('pt-BR') : '—'}
{window.SCP.BRL(selected.total)}
)}
Observação (opcional)