// 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) => (
Materiais, reservas por evento, retirada e devolução.
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.
{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'} />