// Estoque Simplificado — cadastro de materiais, reserva por evento, // retirada e devolucao com status. Orcamento 290. // // O saldo NUNCA e gravado: e sempre derivado de material_reservas, para nao // dessincronizar. Ver calcSaldo() abaixo. const ESTOQUE_CATEGORIAS = [ { value: 'Estrutura', label: 'Estrutura' }, { value: 'Uniforme', label: 'Uniforme' }, { value: 'Comunicação', label: 'Comunicação' }, { value: 'Brindes', label: 'Brindes' }, { value: 'Equipamento', label: 'Equipamento' }, { value: 'Outros', label: 'Outros' }, ]; const SITUACAO_VARIANT = { 'Reservado': 'warning', 'Em campo': 'primary', 'Devolvido': 'success' }; const CONDICAO_VARIANT = { 'Correto': 'success', 'Danificado': 'danger', 'Faltando': 'warning' }; // Saldo de um material a partir das reservas dele. function calcSaldo(material, reservas) { const doMaterial = reservas.filter(r => r.material_id === material.id); const reservado = doMaterial .filter(r => r.situacao === 'Reservado') .reduce((s, r) => s + (r.quantidade || 0), 0); const emCampo = doMaterial .filter(r => r.situacao === 'Em campo') .reduce((s, r) => s + (r.retirado_qtd || 0), 0); const total = material.quantidade_total || 0; return { total, reservado, emCampo, livre: total - reservado - emCampo }; } const fmtDataHora = (iso) => iso ? new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'; // Upload da foto do material no bucket que ja existe (migration 003/009). async function uploadFotoMaterial(file) { const ext = (file.name.split('.').pop() || 'jpg').toLowerCase(); const path = `materiais/${crypto.randomUUID()}.${ext}`; const { error } = await sb.storage.from('event-photos').upload(path, file, { contentType: file.type }); if (error) throw error; const { data: { publicUrl } } = sb.storage.from('event-photos').getPublicUrl(path); return publicUrl; } function Estoque({ currentUser }) { const [materiais, setMateriais] = React.useState([]); const [reservas, setReservas] = React.useState([]); const [loading, setLoading] = React.useState(true); const [selected, setSelected] = React.useState(null); const [addModal, setAddModal] = React.useState(false); const [editModal, setEditModal] = React.useState(false); const [delConfirm, setDelConfirm] = React.useState(false); const [saving, setSaving] = React.useState(false); const [uploading, setUploading] = React.useState(false); const [search, setSearch] = React.useState(''); const [catFilter, setCatFilter] = React.useState('all'); const [toast, setToast] = React.useState(null); const showToast = (msg, type = 'success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 3000); }; const emptyForm = { nome: '', categoria: 'Outros', quantidade_total: '1', status: 'Ativo', foto_url: '', observacoes: '' }; const [form, setForm] = React.useState(emptyForm); const [editForm, setEditForm] = React.useState(emptyForm); const carregar = React.useCallback(async () => { const [{ data: mats }, { data: res }] = await Promise.all([ sb.from('materiais').select('*').order('nome'), sb.from('material_reservas').select('*, events(name)').order('created_at', { ascending: false }), ]); setMateriais(mats || []); setReservas(res || []); setLoading(false); }, []); React.useEffect(() => { carregar(); }, [carregar]); const handleCreate = async () => { if (!form.nome.trim()) { showToast('Preencha o nome do material.', 'danger'); return; } setSaving(true); const { data, error } = await sb.from('materiais').insert({ nome: form.nome.trim(), categoria: form.categoria, quantidade_total: parseInt(form.quantidade_total, 10) || 0, status: form.status, foto_url: form.foto_url || null, observacoes: form.observacoes || null, }).select().single(); setSaving(false); if (error) { console.error('[Estoque.handleCreate] Supabase error:', error); showToast('Erro ao adicionar material: ' + (error.message || 'desconhecido'), 'danger'); return; } setMateriais(prev => [...prev, data].sort((a, b) => a.nome.localeCompare(b.nome))); setAddModal(false); setForm(emptyForm); showToast(`${data.nome} adicionado ao estoque!`); }; const openEdit = () => { setEditForm({ nome: selected.nome || '', categoria: selected.categoria || 'Outros', quantidade_total: String(selected.quantidade_total ?? 0), status: selected.status || 'Ativo', foto_url: selected.foto_url || '', observacoes: selected.observacoes || '', }); setEditModal(true); }; const handleEditSave = async () => { if (!editForm.nome.trim()) { showToast('Preencha o nome.', 'danger'); return; } setSaving(true); const { data, error } = await sb.from('materiais').update({ nome: editForm.nome.trim(), categoria: editForm.categoria, quantidade_total: parseInt(editForm.quantidade_total, 10) || 0, status: editForm.status, foto_url: editForm.foto_url || null, observacoes: editForm.observacoes || null, }).eq('id', selected.id).select().single(); setSaving(false); if (error) { console.error('[Estoque.handleEditSave] Supabase error:', error); showToast('Erro ao salvar.', 'danger'); return; } setMateriais(prev => prev.map(m => m.id === data.id ? data : m).sort((a, b) => a.nome.localeCompare(b.nome))); setSelected(data); setEditModal(false); showToast('Material atualizado!'); }; const handleDelete = async () => { const { id, nome } = selected; const { error } = await sb.from('materiais').delete().eq('id', id); if (error) { console.error('[Estoque.handleDelete] Supabase error:', error); showToast('Erro ao excluir.', 'danger'); return; } setMateriais(prev => prev.filter(m => m.id !== id)); setReservas(prev => prev.filter(r => r.material_id !== id)); setDelConfirm(false); setSelected(null); showToast(`${nome} excluído.`); }; const handleFoto = async (file, setter) => { if (!file) return; setUploading(true); try { const url = await uploadFotoMaterial(file); setter(f => ({ ...f, foto_url: url })); showToast('Foto enviada!'); } catch (err) { console.error('[Estoque.handleFoto] Storage error:', err); showToast('Erro ao enviar a foto.', 'danger'); } setUploading(false); }; const filtrados = React.useMemo(() => { const q = search.trim().toLowerCase(); return materiais.filter(m => { if (catFilter !== 'all' && m.categoria !== catFilter) return false; if (!q) return true; return (m.nome || '').toLowerCase().includes(q) || (m.categoria || '').toLowerCase().includes(q); }); }, [materiais, search, catFilter]); const totais = React.useMemo(() => { return materiais.reduce((acc, m) => { const s = calcSaldo(m, reservas); return { livre: acc.livre + s.livre, reservado: acc.reservado + s.reservado, emCampo: acc.emCampo + s.emCampo }; }, { livre: 0, reservado: 0, emCampo: 0 }); }, [materiais, reservas]); const historico = React.useMemo( () => selected ? reservas.filter(r => r.material_id === selected.id) : [], [selected, reservas] ); const saldoSel = selected ? calcSaldo(selected, reservas) : null; const camposForm = (f, setF) => (
setF(x => ({ ...x, nome: v }))} placeholder="Ex: Wind Banner" />
setF(x => ({ ...x, quantidade_total: v }))} placeholder="Ex: 10" />
handleFoto(e.target.files?.[0], setF)} style={{ fontSize: 12, color: DS.colors.textSec }} /> {uploading && }
setF(x => ({ ...x, observacoes: v }))} placeholder="Ex: guardado no depósito 2" /> ); const COLS = '2fr 1.1fr 0.7fr 0.7fr 0.8fr 0.8fr 0.8fr'; return (

Estoque

Materiais, reservas por evento, retirada e devolução.

{ setForm(emptyForm); setAddModal(true); }}>Novo material
{[ { label: 'Materiais cadastrados', value: materiais.length, color: DS.colors.primary }, { label: 'Livre', value: totais.livre, color: DS.colors.success }, { label: 'Reservado', value: totais.reservado, color: DS.colors.warning }, { label: 'Em campo', value: totais.emCampo, color: DS.colors.purple || DS.colors.primary }, ].map(s => (
{s.label}
{s.value}
))}
setSearch(e.target.value)} placeholder="Buscar por nome ou categoria..." style={{ width: '100%', boxSizing: 'border-box', border: `1px solid ${DS.colors.border}`, borderRadius: DS.radius.sm, padding: '10px 14px 10px 38px', fontSize: 13, outline: 'none', background: '#fff', fontFamily: 'inherit' }} />
{filtrados.length} de {materiais.length}
{loading ? : ( <>
MaterialCategoriaTotalLivreReservadoEm campoStatus
{filtrados.length === 0 ? : filtrados.map((m, i) => { const s = calcSaldo(m, reservas); return (
setSelected(m)} onMouseEnter={e => e.currentTarget.style.background = '#F8FAFC'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> {m.foto_url ? :
} {m.nome}
{m.categoria} {s.total} {s.livre} {s.reservado} {s.emCampo} {m.status}
); })} )}
{/* Detalhe + historico */} setSelected(null)} title={selected?.nome || ''} width={640}> {selected && (
{selected.foto_url ? :
}
{selected.categoria} {selected.status}
{selected.observacoes &&
{selected.observacoes}
}
{[ { label: 'Total', value: saldoSel.total, color: DS.colors.text }, { label: 'Livre', value: saldoSel.livre, color: DS.colors.success }, { label: 'Reservado', value: saldoSel.reservado, color: DS.colors.warning }, { label: 'Em campo', value: saldoSel.emCampo, color: DS.colors.primary }, ].map(s => (
{s.label}
{s.value}
))}
Histórico
{historico.length === 0 ?
Este material ainda não foi usado em nenhum evento.
: (
{historico.map(r => (
{r.events?.name || `Evento #${r.event_id}`} {r.situacao} {r.condicao && {r.condicao}}
Reservado: {r.quantidade} {r.retirado_em && <> · Retirado por {r.retirado_por || '—'} em {fmtDataHora(r.retirado_em)} ({r.retirado_qtd})} {r.devolvido_em && <> · Devolvido por {r.devolvido_por || '—'} em {fmtDataHora(r.devolvido_em)} ({r.devolvido_qtd})} {r.obs && <> · {r.obs}}
))}
)}
setDelConfirm(true)}>Excluir Editar
)}
{ setAddModal(false); setForm(emptyForm); }} title="Novo material" width={560}>
{camposForm(form, setForm)}
{ setAddModal(false); setForm(emptyForm); }}>Cancelar {saving ? 'Salvando…' : 'Adicionar material'}
setEditModal(false)} title="Editar material" width={560}>
{camposForm(editForm, setEditForm)}
setEditModal(false)}>Cancelar {saving ? 'Salvando…' : 'Salvar alterações'}
setDelConfirm(false)} title="Excluir material" width={400}>

Tem certeza que deseja excluir {selected?.nome}? Todo o histórico de reservas dele também será apagado. Esta ação não pode ser desfeita.

setDelConfirm(false)}>Cancelar Excluir
{toast && setToast(null)} />}
); } // ─────────────────────────────────────────────────────────── // Materiais de um evento — aberto pelo detalhe do evento. // Fluxo rapido: reservar -> retirar -> devolver, com poucos campos. // ─────────────────────────────────────────────────────────── function MateriaisEventoModal({ open, onClose, event, currentUser }) { const [materiais, setMateriais] = React.useState([]); const [reservas, setReservas] = React.useState([]); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); const [novoId, setNovoId] = React.useState(''); const [novaQtd, setNovaQtd] = React.useState('1'); const [acao, setAcao] = React.useState(null); // { tipo:'retirar'|'devolver', reserva } const [acaoQtd, setAcaoQtd] = React.useState(''); const [acaoResp, setAcaoResp] = React.useState(''); const [acaoObs, setAcaoObs] = React.useState(''); const [toast, setToast] = React.useState(null); const showToast = (msg, type = 'success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 3000); }; React.useEffect(() => { if (!open || !event?.id) return; setLoading(true); Promise.all([ sb.from('materiais').select('*').eq('status', 'Ativo').order('nome'), sb.from('material_reservas').select('*').order('created_at', { ascending: false }), ]).then(([{ data: mats }, { data: res }]) => { setMateriais(mats || []); setReservas(res || []); setLoading(false); }); }, [open, event?.id]); const doEvento = React.useMemo( () => reservas.filter(r => r.event_id === event?.id), [reservas, event?.id] ); const materialPorId = React.useMemo(() => { const map = {}; materiais.forEach(m => { map[m.id] = m; }); return map; }, [materiais]); const handleReservar = async () => { const mat = materialPorId[novoId]; const qtd = parseInt(novaQtd, 10) || 0; if (!mat) { showToast('Escolha um material.', 'danger'); return; } if (qtd <= 0) { showToast('Informe a quantidade.', 'danger'); return; } const saldo = calcSaldo(mat, reservas); if (qtd > saldo.livre) { showToast(`Só há ${saldo.livre} livre(s) de ${mat.nome}.`, 'danger'); return; } setSaving(true); const { data, error } = await sb.from('material_reservas') .insert({ material_id: mat.id, event_id: event.id, quantidade: qtd, situacao: 'Reservado' }) .select().single(); setSaving(false); if (error) { console.error('[MateriaisEventoModal.handleReservar] Supabase error:', error); showToast('Erro ao reservar: ' + (error.message || 'desconhecido'), 'danger'); return; } setReservas(prev => [data, ...prev]); setNovoId(''); setNovaQtd('1'); showToast(`${qtd}× ${mat.nome} reservado.`); }; const abrirAcao = (tipo, reserva) => { setAcao({ tipo, reserva }); setAcaoQtd(String(tipo === 'retirar' ? reserva.quantidade : (reserva.retirado_qtd ?? reserva.quantidade))); setAcaoResp(currentUser?.name || ''); setAcaoObs(''); }; const confirmarRetirada = async () => { const qtd = parseInt(acaoQtd, 10) || 0; if (qtd <= 0) { showToast('Informe a quantidade.', 'danger'); return; } setSaving(true); const { data, error } = await sb.from('material_reservas').update({ situacao: 'Em campo', retirado_em: new Date().toISOString(), retirado_por: acaoResp || null, retirado_qtd: qtd, }).eq('id', acao.reserva.id).select().single(); setSaving(false); if (error) { console.error('[MateriaisEventoModal.confirmarRetirada] Supabase error:', error); showToast('Erro ao registrar a retirada.', 'danger'); return; } setReservas(prev => prev.map(r => r.id === data.id ? data : r)); setAcao(null); showToast('Retirada registrada!'); }; const confirmarDevolucao = async (condicao) => { const qtd = parseInt(acaoQtd, 10) || 0; setSaving(true); const { data, error } = await sb.from('material_reservas').update({ situacao: 'Devolvido', devolvido_em: new Date().toISOString(), devolvido_por: acaoResp || null, devolvido_qtd: qtd, condicao, obs: acaoObs || null, }).eq('id', acao.reserva.id).select().single(); setSaving(false); if (error) { console.error('[MateriaisEventoModal.confirmarDevolucao] Supabase error:', error); showToast('Erro ao registrar a devolução.', 'danger'); return; } setReservas(prev => prev.map(r => r.id === data.id ? data : r)); setAcao(null); showToast(`Devolução registrada (${condicao}).`); }; const handleExcluirReserva = async (reserva) => { const { error } = await sb.from('material_reservas').delete().eq('id', reserva.id); if (error) { console.error('[MateriaisEventoModal.handleExcluirReserva] Supabase error:', error); showToast('Erro ao remover.', 'danger'); return; } setReservas(prev => prev.filter(r => r.id !== reserva.id)); showToast('Reserva removida.'); }; return ( {loading ? : (
{/* Reservar */}
Reservar material
Reservar
{/* Lista do evento */}
Materiais deste evento ({doEvento.length})
{doEvento.length === 0 ? : (
{doEvento.map(r => { const m = materialPorId[r.material_id]; return (
{m?.nome || `Material #${r.material_id}`} · {r.quantidade}
{r.retirado_em && <>Retirado por {r.retirado_por || '—'} em {fmtDataHora(r.retirado_em)}} {r.devolvido_em && <> · Devolvido em {fmtDataHora(r.devolvido_em)}}
{r.situacao} {r.condicao && {r.condicao}} {r.situacao === 'Reservado' && ( <> abrirAcao('retirar', r)}>Retirar handleExcluirReserva(r)}>Remover )} {r.situacao === 'Em campo' && ( abrirAcao('devolver', r)}>Devolver )}
); })}
)}
Fechar
)} {/* Retirada / devolucao */} setAcao(null)} title={acao?.tipo === 'retirar' ? 'Registrar retirada' : 'Confirmar devolução'} width={420}> {acao && (
{materialPorId[acao.reserva.material_id]?.nome} {' '}· reservado: {acao.reserva.quantidade}
{acao.tipo === 'retirar' ? (
setAcao(null)}>Cancelar {saving ? 'Salvando…' : 'Confirmar retirada'}
) : ( <>
Como o material voltou?
confirmarDevolucao('Correto')} disabled={saving}>Correto confirmarDevolucao('Danificado')} disabled={saving}>Danificado confirmarDevolucao('Faltando')} disabled={saving}>Faltando
)}
)}
{toast && setToast(null)} />}
); } Object.assign(window, { Estoque, MateriaisEventoModal, calcSaldo, ESTOQUE_CATEGORIAS });