// 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" />
);
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}
))}
{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 => (
))}
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 */}
{/* 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 && (
)}
{toast && setToast(null)} />}
);
}
Object.assign(window, { Estoque, MateriaisEventoModal, calcSaldo, ESTOQUE_CATEGORIAS });