// 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. Continua DERIVADO, nunca gravado (ver migration 025). // // A partir do orcamento 291 o "em campo" vem do romaneio, nao mais da reserva: // reservado = reservas que ainda nao viraram romaneio (saida_id null) // em campo = o que saiu num romaneio Confirmado e ainda nao voltou // O termo emCampoLegado existe so por causa das linhas antigas, retiradas pelo // fluxo item a item que existia antes do romaneio. Some sozinho com o tempo. function calcSaldo(material, reservas, saidaItens) { const doMaterial = (reservas || []).filter(r => r.material_id === material.id); const reservado = doMaterial .filter(r => r.situacao === 'Reservado' && !r.saida_id) .reduce((s, r) => s + (r.quantidade || 0), 0); const emCampoLegado = doMaterial .filter(r => r.situacao === 'Em campo' && !r.saida_id) .reduce((s, r) => s + (r.retirado_qtd || 0), 0); const emCampoRomaneio = (saidaItens || []) .filter(i => i.material_id === material.id) .reduce((s, i) => s + Math.max(0, (i.qtd_enviada || 0) - (i.qtd_recebida || 0)), 0); const emCampo = emCampoLegado + emCampoRomaneio; const total = material.quantidade_total || 0; return { total, reservado, emCampo, livre: total - reservado - emCampo }; } // Situacao exibida do equipamento. "Em acao" nao existe no banco de proposito: // e derivado, igual ao saldo. Ver migration 035. function situacaoExibida(material, saldo) { if (saldo && saldo.emCampo > 0) return 'Em ação'; return material.situacao_fisica || 'Disponível'; } 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 [saidaItens, setSaidaItens] = React.useState([]); const [liberarModal, setLiberarModal] = React.useState(null); // 'liberar' | 'baixa' const [liberarForm, setLiberarForm] = React.useState({ qtd: '1', obs: '' }); 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 }, { data: its }] = await Promise.all([ sb.from('materiais').select('*').order('nome'), sb.from('material_reservas').select('*, events(name)').order('created_at', { ascending: false }), // So os romaneios que ainda prendem saldo. Conferido e cancelado ja // devolveram o material, entao nao entram no calculo. sb.from('material_saida_itens') .select('*, material_saidas!inner(id, status, event_id, saida_em, responsavel_retirada, responsavel_entrega, conferido_por, conferido_em, events(name))') .eq('material_saidas.status', 'Confirmada'), ]); setMateriais(mats || []); setReservas(res || []); setSaidaItens(its || []); 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, saidaItens); return { livre: acc.livre + s.livre, reservado: acc.reservado + s.reservado, emCampo: acc.emCampo + s.emCampo }; }, { livre: 0, reservado: 0, emCampo: 0 }); }, [materiais, reservas, saidaItens]); // Reservas ainda pendentes aparecem no historico como planejamento; o que // aconteceu de verdade vem dos romaneios (historicoSaidas). const historico = React.useMemo( () => selected ? reservas.filter(r => r.material_id === selected.id) : [], [selected, reservas] ); // Historico por equipamento: quem entregou, quem recebeu e quem conferiu. // Busca separada porque aqui interessam TODOS os romaneios, nao so os que // ainda prendem saldo. const [historicoSaidas, setHistoricoSaidas] = React.useState([]); React.useEffect(() => { if (!selected) { setHistoricoSaidas([]); return; } let cancelado = false; sb.from('material_saida_itens') .select('*, material_saidas(id, status, saida_em, responsavel_retirada, responsavel_entrega, conferido_por, conferido_em, events(name))') .eq('material_id', selected.id) .order('id', { ascending: false }) .then(({ data, error }) => { if (error) { console.error('[Estoque.historicoSaidas] Supabase error:', error); return; } if (!cancelado) setHistoricoSaidas(data || []); }); return () => { cancelado = true; }; }, [selected?.id]); const saldoSel = selected ? calcSaldo(selected, reservas, saidaItens) : null; const situacaoSel = selected ? situacaoExibida(selected, saldoSel) : null; // Volta o material para circulacao (ou tira em definitivo). Sem isso, item // que voltou danificado nunca mais sairia do estoque. const aplicarLiberacao = async () => { if (!selected) return; setSaving(true); const patch = { situacao_fisica: 'Disponível', situacao_obs: liberarForm.obs?.trim() || null, situacao_alterada_em: new Date().toISOString(), situacao_alterada_por: currentUser?.name || null, }; if (liberarModal === 'baixa') { const qtd = parseInt(liberarForm.qtd, 10) || 0; if (qtd <= 0 || qtd > (selected.quantidade_total || 0)) { setSaving(false); showToast(`Informe uma quantidade entre 1 e ${selected.quantidade_total}.`, 'danger'); return; } patch.quantidade_total = (selected.quantidade_total || 0) - qtd; } const { data, error } = await sb.from('materiais').update(patch).eq('id', selected.id).select().single(); setSaving(false); if (error) { console.error('[Estoque.aplicarLiberacao] Supabase error:', error); showToast('Erro ao atualizar a situação: ' + (error.message || 'desconhecido'), 'danger'); return; } setMateriais(prev => prev.map(m => m.id === data.id ? data : m)); setSelected(data); setLiberarModal(null); setLiberarForm({ qtd: '1', obs: '' }); showToast(liberarModal === 'baixa' ? 'Baixa registrada.' : 'Material liberado para uso.'); }; 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 campoSituação
{filtrados.length === 0 ? : filtrados.map((m, i) => { const s = calcSaldo(m, reservas, saidaItens); const sit = situacaoExibida(m, s); 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} {sit}
); })} )}
{/* Detalhe + historico */} setSelected(null)} title={selected?.nome || ''} width={640}> {selected && (
{selected.foto_url ? :
}
{selected.categoria} {selected.status} {situacaoSel}
{selected.observacoes &&
{selected.observacoes}
}
{/* Situacao fisica: so aparece quando ha algo a resolver */} {situacaoSel !== 'Disponível' && situacaoSel !== 'Em ação' && (
Equipamento {situacaoSel.toLowerCase()}
{selected.situacao_obs || 'Sem observação registrada.'} {selected.situacao_alterada_em && ( <> · Registrado por {selected.situacao_alterada_por || '—'} em {fmtDataHora(selected.situacao_alterada_em)} )}
{ setLiberarForm({ qtd: '1', obs: '' }); setLiberarModal('liberar'); }}> Voltar para Disponível { setLiberarForm({ qtd: '1', obs: '' }); setLiberarModal('baixa'); }}> Dar baixa definitiva
)}
{[ { 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}
))}
{/* Historico por equipamento — o que saiu de verdade, por acao */}
Saídas e retornos ({historicoSaidas.length})
{historicoSaidas.length === 0 ?
Este material ainda não saiu em nenhum romaneio.
: (
{historicoSaidas.map(it => { const s = it.material_saidas || {}; return (
{s.events?.name || 'Ação removida'} · {romaneioNumero(s)} {s.status} {it.ocorrencia && {it.ocorrencia}}
Saiu {it.qtd_enviada} ({it.estado_saida}) em {fmtDataHora(s.saida_em)} {s.responsavel_retirada && <> · Retirou: {s.responsavel_retirada}} {s.responsavel_entrega && <> · Entregou: {s.responsavel_entrega}} {it.retorno && <> · Retorno: {it.retorno} ({it.qtd_recebida})} {s.conferido_por && <> · Conferiu: {s.conferido_por} em {fmtDataHora(s.conferido_em)}} {it.obs_retorno && <> · {it.obs_retorno}}
{it.foto_url && ( Avaria )}
); })}
)}
Reservas
{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
{/* Liberacao / baixa definitiva do equipamento */} setLiberarModal(null)} title={liberarModal === 'baixa' ? 'Dar baixa definitiva' : 'Voltar para Disponível'} width={440}>

{liberarModal === 'baixa' ? <>O material sai do estoque em definitivo: a quantidade total de {selected?.nome} diminui. Use quando o equipamento foi perdido ou não tem mais conserto. : <>{selected?.nome} volta a ficar disponível para as próximas ações.}

{liberarModal === 'baixa' && ( setLiberarForm(f => ({ ...f, qtd: v }))} /> )} setLiberarForm(f => ({ ...f, obs: v }))} placeholder={liberarModal === 'baixa' ? 'Ex: quebrado sem conserto' : 'Ex: voltou da manutenção'} />
setLiberarModal(null)}>Cancelar {saving ? 'Salvando…' : 'Confirmar'}
{toast && setToast(null)} />}
); } // ─────────────────────────────────────────────────────────── // Materiais de um evento — aberto pelo detalhe do evento. // // A reserva e o PLANEJAMENTO do que vai para a acao. A saida de verdade // acontece no romaneio (orcamento 291), e o retorno na conferencia. Os botoes // de retirar/devolver item a item sairam de proposito: dois caminhos para dar // a mesma baixa era o que dessincronizava o saldo. // ─────────────────────────────────────────────────────────── function MateriaisEventoModal({ open, onClose, event, currentUser }) { const [materiais, setMateriais] = React.useState([]); const [reservas, setReservas] = React.useState([]); const [saidas, setSaidas] = React.useState([]); const [saidaItens, setSaidaItens] = 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 [toast, setToast] = React.useState(null); const showToast = (msg, type = 'success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 3000); }; const carregar = React.useCallback(async () => { const [{ data: mats }, { data: res }, { data: sai }, { data: its }] = await Promise.all([ sb.from('materiais').select('*').eq('status', 'Ativo').order('nome'), sb.from('material_reservas').select('*').order('created_at', { ascending: false }), sb.from('material_saidas').select('*').order('id', { ascending: false }), // Todos os itens: o painel precisa deles para listar, e o saldo filtra // depois pelos romaneios que ainda estao Confirmados. sb.from('material_saida_itens').select('*, material_saidas(status)'), ]); setMateriais(mats || []); setReservas(res || []); setSaidas(sai || []); setSaidaItens(its || []); setLoading(false); }, []); React.useEffect(() => { if (!open || !event?.id) return; setLoading(true); carregar(); }, [open, event?.id, carregar]); // Só o que ainda prende saldo entra no cálculo do livre. const itensPrendendoSaldo = React.useMemo( () => saidaItens.filter(i => i.material_saidas?.status === 'Confirmada'), [saidaItens] ); 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, itensPrendendoSaldo); 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 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
{/* Planejamento do que vai para a acao */}
Reservado para esta ação ({doEvento.length})
{doEvento.length === 0 ? : (
{doEvento.map(r => { const m = materialPorId[r.material_id]; return (
{m?.nome || `Material #${r.material_id}`} · {r.quantidade}
{r.saida_id && (
Já saiu no {romaneioNumero({ id: r.saida_id })}
)}
{r.saida_id ? 'Em campo' : r.situacao} {!r.saida_id && r.situacao === 'Reservado' && ( handleExcluirReserva(r)}>Remover )}
); })}
)}
Fechar
)} {toast && setToast(null)} />}
); } Object.assign(window, { Estoque, MateriaisEventoModal, calcSaldo, situacaoExibida, ESTOQUE_CATEGORIAS });