// ---- Adapter: DB row → UI shape ---- const toUiProduct = (row) => ({ ...row, min: row.min_stock, lastPrice: row.last_price ?? 0, avgPrice: row.avg_price ?? row.last_price ?? 0, photo: row.photo_url, supplier: row.preferred_supplier?.name || '—', }); function ScreenProducts({ setRoute }) { const { CATEGORIES, BRL } = window.SCP; const [q, setQ] = useState(''); const [cat, setCat] = useState('todas'); const [openProd, setOpenProd] = useState(null); const [rows, setRows] = useState([]); const [suppliers, setSuppliers] = useState([]); const [loading, setLoading] = useState(true); const [loadErr, setLoadErr] = useState(null); const [editing, setEditing] = useState(null); // null | 'new' | productObject const [highlightId, setHighlightId] = useState(null); useEffect(() => { let mounted = true; (async () => { try { const [prods, sups] = await Promise.all([ window.scpDb.products.list(), window.scpDb.suppliers.list(), ]); if (!mounted) return; setRows(prods.map(toUiProduct)); setSuppliers(sups); } catch (e) { if (mounted) setLoadErr(e.message || 'Falha ao carregar produtos'); } finally { if (mounted) setLoading(false); } })(); return () => { mounted = false; }; }, []); const list = rows.filter(p => (cat === 'todas' || p.cat === cat) && (!q || (p.name + (p.code || '') + (p.brand || '')).toLowerCase().includes(q.toLowerCase())) ); const kpis = useMemo(() => { const total = rows.length; const lowStock = rows.filter(p => Number(p.stock) < Number(p.min)).length; const validPrices = rows.filter(p => p.lastPrice > 0); const avg = validPrices.length ? validPrices.reduce((s, p) => s + Number(p.lastPrice), 0) / validPrices.length : 0; const supSet = new Set(rows.map(p => p.preferred_supplier_id).filter(Boolean)); const catSet = new Set(rows.map(p => p.cat).filter(Boolean)); return { total, lowStock, avg, sups: supSet.size, cats: catSet.size }; }, [rows]); const handleDuplicate = async (p) => { try { const payload = { code: (p.code || '78' + Date.now()) + '-D', name: p.name + ' (cópia)', brand: p.brand, cat: p.cat, unit: p.unit, stock: 0, min_stock: p.min, last_price: p.lastPrice, avg_price: p.avgPrice, preferred_supplier_id: p.preferred_supplier_id || null, photo_url: p.photo, }; const created = await window.scpDb.products.create(payload); const ui = toUiProduct(created); setRows(prev => [ui, ...prev]); setOpenProd(null); setHighlightId(ui.id); window.scpToast('Produto duplicado', { kind: 'emerald', sub: ui.name }); setTimeout(() => setHighlightId(null), 2400); } catch (e) { window.scpToast('Erro ao duplicar', { kind: 'coral', sub: e.message }); } }; const handleAddToQuote = (p) => { setOpenProd(null); window.scpToast(`Adicionado à nova cotação`, { kind: 'emerald', sub: p.name }); setTimeout(() => setRoute && setRoute('newQuotation'), 500); }; const handleSave = async (data) => { try { const payload = { code: data.code || '78' + Date.now(), name: data.name, brand: data.brand || null, cat: data.cat, unit: data.unit || 'un', stock: Number(data.stock) || 0, min_stock: Number(data.min) || 0, last_price: data.lastPrice ? Number(data.lastPrice) : null, avg_price: data.lastPrice ? Number(data.lastPrice) : null, preferred_supplier_id: data.supplier_id || null, photo_url: data.photo_url || null, }; const isEdit = editing && editing !== 'new'; const saved = isEdit ? await window.scpDb.products.update(editing.id, payload) : await window.scpDb.products.create(payload); const ui = toUiProduct(saved); setRows(prev => isEdit ? prev.map(p => p.id === ui.id ? ui : p) : [ui, ...prev]); setEditing(null); setHighlightId(ui.id); window.scpToast(isEdit ? 'Produto atualizado' : 'Produto cadastrado', { kind: 'emerald', sub: ui.name }); setTimeout(() => setHighlightId(null), 2400); } catch (e) { window.scpToast('Erro ao salvar', { kind: 'coral', sub: e.message }); } }; const handleDelete = async (p) => { if (!confirm(`Remover "${p.name}"? O produto ficará oculto da lista.`)) return; try { await window.scpDb.products.remove(p.id); setRows(prev => prev.filter(x => x.id !== p.id)); setOpenProd(null); window.scpToast('Produto removido', { kind: 'amber', sub: p.name }); } catch (e) { window.scpToast('Erro ao remover', { kind: 'coral', sub: e.message }); } }; return (
}/> 0 ? 'Sugerir cotação' : 'Tudo em dia'} delta={kpis.lowStock > 0 ? 'reposição' : 'ok'} deltaTone={kpis.lowStock > 0 ? 'amber' : 'emerald'} icon={}/> }/> }/>
{loadErr && ( Falha ao carregar produtos:{' '} {loadErr}
Verifique se o schema, seed-suppliers, seed-products e storage.sql foram rodados.
)}
{CATEGORIES.map(c => ( ))}
} placeholder="Buscar produto, código de barras…" value={q} onChange={e => setQ(e.target.value)} /> } onClick={() => window.scpToast('Câmera não disponível em demo', { kind: 'amber', sub: 'Aponte para o código de barras na versão final' })}>Ler código } onClick={() => window.scpToast('Pronto para receber planilha', { kind: 'sky', sub: 'Aceita .xlsx, .csv e .ods com até 5MB' })}>Importar } onClick={() => setEditing('new')}>Novo Produto
{loading && ( )} {!loading && list.length === 0 && ( )} {!loading && list.map(p => { const c = CATEGORIES.find(c => c.key === p.cat); const low = p.stock < p.min; const ratio = p.stock / p.min; const seed = parseInt(p.id.slice(-3), 10) || 0; const spark = [80, 88, 84, 92, 86, 82, 90, 87, 85, 80].map((v, i) => v + ((seed * (i+1)) % 9) - 4); return ( setOpenProd(p)}> ); })}
Produto Categoria Unidade Estoque Mínimo Últ. preço Preço médio Melhor fornecedor Tendência (90d)
Carregando produtos…
{rows.length === 0 ? 'Nenhum produto cadastrado ainda.' : 'Nenhum produto com este filtro.'}
{p.photo ? {p.name}/ : }
{p.name}
{p.code} · {p.id}{p.brand && p.brand !== '—' ? ` · ${p.brand}` : ''}
{c.label} {p.unit} {p.stock}
{p.min} {BRL(p.lastPrice)} {BRL(p.avgPrice)} {p.supplier} } size="sm"/>
{editing && ( setEditing(null)} onSave={handleSave} categories={CATEGORIES} suppliers={suppliers} /> )} setOpenProd(null)} eyebrow={openProd?.code} title={openProd?.name || ''} headerRight={openProd && (() => { const c = CATEGORIES.find(x => x.key === openProd.cat); return c ? {c.label} : null; })()} footer={openProd && ( <> } onClick={() => handleDelete(openProd)} style={{ color: 'oklch(0.82 0.14 25)', marginRight: 'auto' }}>Excluir } onClick={() => { setEditing(openProd); setOpenProd(null); }}>Editar } onClick={() => handleDuplicate(openProd)}>Duplicar } onClick={() => handleAddToQuote(openProd)}>Adicionar à cotação )} > {openProd && }
); } function ProductDetail({ product: p, categories }) { const { BRL } = window.SCP; const c = categories.find(x => x.key === p.cat); const low = Number(p.stock) < Number(p.min); const ratio = p.min > 0 ? Math.min(100, (Number(p.stock) / Number(p.min)) * 100) : 0; const diff = (p.lastPrice || 0) - (p.avgPrice || 0); const diffPct = p.avgPrice ? ((diff / p.avgPrice) * 100).toFixed(1) : '0.0'; const supplier = p.preferred_supplier; // já vem populado do join const seed = parseInt(p.id.slice(-3), 10) || 0; const spark = [80, 88, 84, 92, 86, 82, 90, 87, 85, 80].map((v, i) => v + ((seed * (i+1)) % 9) - 4); return ( <>
{p.photo ? {p.name} : }
{p.name}
{p.code} · {p.id} · {p.unit}{p.brand && p.brand !== '—' ? ` · marca ${p.brand}` : ''}
{c && {c.label}}

Preço

Último preço {BRL(p.lastPrice)} {p.unit}
Preço médio (90d) {BRL(p.avgPrice)} {diff < 0 ? '↓' : '↑'} {Math.abs(diffPct)}% vs. médio
Tendência 90 dias

Estoque

Atual {p.stock} {p.unit} {low ? 'Abaixo do mínimo' : 'Saudável'}
Mínimo {p.min} Limite para reposição
{ratio.toFixed(0)}% do mínimo

Fornecedor preferencial

{supplier ? (
{supplier.name}
{supplier.city || '—'} · score {supplier.score}
{supplier.avg_delivery || '—'}
) : (
Nenhum fornecedor preferencial definido.
)}
); } function ProductFormModal({ mode, initial, onClose, onSave, categories, suppliers }) { const isEdit = mode === 'edit'; const [name, setName] = useState(initial?.name || ''); const [code, setCode] = useState(initial?.code || ''); const [brand, setBrand] = useState(initial?.brand || ''); const [cat, setCat] = useState(initial?.cat || 'secos'); const [unit, setUnit] = useState(initial?.unit || 'un'); const [stock, setStock] = useState(initial?.stock ?? ''); const [min, setMin] = useState(initial?.min ?? ''); const [lastPrice, setLastPrice] = useState(initial?.lastPrice ?? ''); const [supplierId, setSupplierId] = useState(initial?.preferred_supplier_id || ''); const [photoUrl, setPhotoUrl] = useState(initial?.photo || null); const [previewUrl, setPreviewUrl] = useState(initial?.photo || null); const [uploading, setUploading] = useState(false); const [saving, setSaving] = useState(false); const canSubmit = name.trim().length >= 2 && !uploading && !saving; const onPhoto = async (e) => { const file = e.target.files?.[0]; if (!file) return; // Preview imediato const reader = new FileReader(); reader.onload = (ev) => setPreviewUrl(ev.target.result); reader.readAsDataURL(file); // Upload no Storage setUploading(true); try { const url = await window.scpDb.products.uploadPhoto(file, code || name); setPhotoUrl(url); window.scpToast('Foto enviada', { kind: 'emerald' }); } catch (err) { window.scpToast('Falha no upload', { kind: 'coral', sub: err.message }); setPreviewUrl(photoUrl); // volta ao anterior } finally { setUploading(false); } }; const submit = async () => { if (!canSubmit) return; setSaving(true); try { await onSave({ name: name.trim(), code: code.trim(), brand: brand.trim(), cat, unit: unit.trim(), stock, min, lastPrice, supplier_id: supplierId || null, photo_url: photoUrl, }); } finally { setSaving(false); } }; return ReactDOM.createPortal((
e.stopPropagation()}>

{isEdit ? 'Editar Produto' : 'Novo Produto'}

} size="sm" onClick={onClose}/>
Nome do produto * setName(e.target.value)} autoFocus />
Marca setBrand(e.target.value)} />
Código de barras setCode(e.target.value)} />
Unidade setUnit(e.target.value)} />
Categoria
Estoque atual setStock(e.target.value)} />
Estoque mínimo setMin(e.target.value)} />
Último preço (R$) setLastPrice(e.target.value)} />
Fornecedor preferencial
{uploading ? 'Enviando foto…' : saving ? 'Salvando…' : isEdit ? 'As alterações serão aplicadas ao produto.' : 'Preencha o nome para habilitar o cadastro.'}
Cancelar }> {isEdit ? 'Salvar alterações' : 'Cadastrar produto'}
), document.body); } (function() { if (document.getElementById('scp-products-css')) return; const s = document.createElement('style'); s.id = 'scp-products-css'; s.textContent = ` .scp-table tbody tr.just-added td { background: oklch(0.26 0.06 160 / .35); animation: justAddedPulseRow 2.4s ease-out; } @keyframes justAddedPulseRow { 0% { background: oklch(0.34 0.10 160 / .55); box-shadow: inset 3px 0 0 oklch(0.78 0.16 160); } 100% { background: oklch(0.205 0.012 250); box-shadow: inset 3px 0 0 transparent; } } .scp-photo-upload { width: 96px; height: 96px; border-radius: 12px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; background: var(--surface-2); border: 2px dashed var(--border-strong); color: var(--fg-3); font-size: 11px; font-weight: 600; transition: border-color .15s, color .15s; overflow: hidden; } .scp-photo-upload:hover { border-color: oklch(0.55 0.10 160); color: var(--fg-2); } .scp-photo-upload img { width: 100%; height: 100%; object-fit: cover; } .scp-prod-photo { width: 38px; height: 38px; border-radius: 8px; background: var(--surface-2); border: 1px solid var(--border-soft); display: grid; place-items: center; color: var(--fg-3); overflow: hidden; flex-shrink: 0; } .scp-prod-photo img { width: 100%; height: 100%; object-fit: cover; } `; document.head.appendChild(s); })(); window.ScreenProducts = ScreenProducts;