// Compras — Ordens de Compra + Cotações
//
// Fecha o fluxo que começa em Cotacoes.jsx:
// ... Escolher fornecedor -> Gerar Ordem de Compra -> fornecedor confirma
// pelo link -> a Edge Function 'oc-publica' lança a conta a pagar no
// Financeiro (transactions, despesa Pendente).
//
// A OC guarda SNAPSHOT do fornecedor/evento na criação: documento enviado
// não pode mudar de conteúdo porque alguém editou o cadastro depois.
const OC_STATUS_VARIANT = {
Rascunho: 'default', Enviada: 'primary', Confirmada: 'success',
Recusada: 'danger', Cancelada: 'default',
};
const FORMAS_PAGAMENTO = ['PIX', 'Transferência', 'Boleto', 'Cartão', 'Dinheiro'];
const dataBR = (d) => d ? new Date(String(d).slice(0, 10) + 'T00:00:00').toLocaleDateString('pt-BR') : '—';
// Soma dos itens — fonte única do valor total, na tela e no PDF.
function somaItens(itens) {
return (itens || []).reduce((s, it) => s + (Number(it.qtd) || 0) * (Number(it.valor_unit) || 0), 0);
}
// Texto padronizado enviado ao fornecedor junto com o PDF
function mensagemOC({ oc, link }) {
return [
`Olá, ${oc.fornecedor_contato || oc.fornecedor_nome}! Aqui é da Promoup.`,
'',
`Segue a *Ordem de Compra ${oc.numero}*:`,
'',
oc.evento_nome ? `Evento: ${oc.evento_nome}${oc.evento_data ? ` — ${dataBR(oc.evento_data)}` : ''}` : '',
oc.evento_local ? `Local: ${oc.evento_local}` : '',
oc.descricao ? `Serviço: ${oc.descricao}` : '',
`Valor total: *${fmtBRL(oc.valor_total)}*`,
oc.data_servico ? `Prazo de execução: ${dataBR(oc.data_servico)}` : '',
`Pagamento: ${oc.forma_pagamento || 'PIX'}${oc.prazo_pagamento ? ` — ${oc.prazo_pagamento}` : ''}`,
'',
'Confirme ou recuse o serviço por aqui:',
link,
].join('\n').replace(/\n{3,}/g, '\n\n');
}
// Gera o PDF como Blob (para subir no Storage e mandar no WhatsApp).
// Reusa o mesmo preparo do renderPDF de EventReports (mountPDFClone +
// pdfOptions): o template fica invisível dentro da tela, então precisa ser
// clonado num wrapper visível-mas-atrás e normalizado para a largura da
// folha — senão o html2canvas produz uma página em branco ou cortada.
async function ocPdfBlob(node) {
if (!node || !window.html2pdf || !window.mountPDFClone) return null;
const mounted = await window.mountPDFClone(node);
if (!mounted) return null;
const { wrapper, clone } = mounted;
try {
return await window.html2pdf().set(window.pdfOptions()).from(clone).outputPdf('blob');
} catch (err) {
console.error('[oc-pdf] falha ao gerar:', err);
return null;
} finally {
if (wrapper.parentNode) document.body.removeChild(wrapper);
}
}
// ---------------------------------------------------------------------
// Template do PDF da OC
// ---------------------------------------------------------------------
function OCPDFTemplate({ oc, innerRef }) {
if (!oc) return null;
const b = oc.dados_bancarios || {};
const itens = oc.itens || [];
const td = { padding: '7px 9px', borderBottom: '1px solid #E2E8F0', fontSize: 11, color: '#334155' };
const th = { padding: '7px 9px', borderBottom: '2px solid #CBD5E1', fontSize: 9, fontWeight: 700, color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.05em', textAlign: 'left' };
const campo = (k, v) => (
{campo('Prazo de execução', dataBR(oc.data_servico))}
{campo('Forma de pagamento', oc.forma_pagamento)}
{campo('Prazo de pagamento', oc.prazo_pagamento)}
{/* Dados bancários */}
{(b.banco || b.pix_chave) && (
A confirmação deste documento pelo fornecedor, por escrito ou pelo link enviado, caracteriza aceite das condições acima.
{oc.status === 'Confirmada' && oc.responded_at
? `CONFIRMADA pelo fornecedor em ${new Date(oc.responded_at).toLocaleString('pt-BR')}.`
: 'Aguardando confirmação do fornecedor.'}
);
}
// ---------------------------------------------------------------------
// Módulo Compras
// ---------------------------------------------------------------------
function Compras({ onNavigate, currentUser }) {
const [aba, setAba] = React.useState(() => localStorage.getItem('ef_compras_aba') || 'ocs');
const [ocs, setOcs] = React.useState([]);
const [fornecedores, setFornecedores] = React.useState([]);
const [events, setEvents] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [abertaId, setAbertaId] = React.useState(null);
const [novaModal, setNovaModal] = React.useState(false);
// Id da OC em correcao. Guardamos so o id, como em abertaId, para o
// formulario reabrir sempre com a versao mais nova da lista.
const [editandoId, setEditandoId] = React.useState(null);
const [filtro, setFiltro] = React.useState('todos');
// Quando a pessoa chega pelo botão "Compras do evento" na Agenda,
// a busca já vem preenchida com o nome do evento.
const [busca, setBusca] = React.useState(() => {
const vindoDoEvento = localStorage.getItem('ef_compras_evento');
if (vindoDoEvento) localStorage.removeItem('ef_compras_evento');
return vindoDoEvento || '';
});
const [busy, setBusy] = React.useState(null);
const [cancelConfirm, setCancelConfirm] = React.useState(null);
const [toast, setToast] = React.useState(null);
const showToast = (msg, type = 'success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 4500); };
const pdfRef = React.useRef(null);
const trocarAba = (id) => { setAba(id); localStorage.setItem('ef_compras_aba', id); };
const carregar = React.useCallback(async () => {
const [{ data: o }, { data: f }, { data: e }] = await Promise.all([
sb.from('purchase_orders').select('*').order('created_at', { ascending: false }),
sb.from('fornecedores').select('*').order('nome'),
sb.from('events').select('id, name, event_date, client_name, location').order('event_date', { ascending: false }).limit(200),
]);
setOcs(o || []); setFornecedores(f || []); setEvents(e || []);
setLoading(false);
}, []);
React.useEffect(() => { carregar(); }, [carregar]);
// Guardamos só o id: o detalhe sempre lê a versão mais nova da lista.
const aberta = ocs.find(o => o.id === abertaId) || null;
const editando = ocs.find(o => o.id === editandoId) || null;
// Conteúdo da OC — o mesmo na criação e na correção. Os campos de snapshot
// são recopiados do cadastro atual do fornecedor e do evento: se a pessoa
// trocou o fornecedor errado pelo certo, os dados bancários têm que vir junto.
const conteudoOC = ({ fornecedor, evento, descricao, itens, data_servico, forma_pagamento, prazo_pagamento, observacoes }) => ({
fornecedor_id: fornecedor.id,
fornecedor_nome: fornecedor.nome,
fornecedor_contato: fornecedor.contato || null,
fornecedor_email: fornecedor.email || null,
fornecedor_telefone: fornecedor.telefone || null,
dados_bancarios: {
banco: fornecedor.banco || '', agencia: fornecedor.agencia || '', conta: fornecedor.conta || '',
tipo_conta: fornecedor.tipo_conta || '', pix_tipo: fornecedor.pix_tipo || '', pix_chave: fornecedor.pix_chave || '',
favorecido: fornecedor.favorecido || fornecedor.nome, favorecido_doc: fornecedor.favorecido_doc || '',
},
event_id: evento ? evento.id : null,
evento_nome: evento ? evento.name : null,
evento_data: evento ? evento.event_date : null,
evento_local: evento ? evento.location : null,
cliente_nome: evento ? evento.client_name : null,
descricao: descricao || null,
itens: itens || [],
valor_total: somaItens(itens),
data_servico: data_servico || null,
forma_pagamento: forma_pagamento || 'PIX',
prazo_pagamento: prazo_pagamento || null,
observacoes: observacoes || null,
});
// ---- criação da OC (usada pelo modal e pela aba de Cotações) ----
const criarOC = async ({ fornecedor, evento, descricao, itens, data_servico, forma_pagamento, prazo_pagamento, observacoes, quote_request_supplier_id = null }) => {
const payload = {
...conteudoOC({ fornecedor, evento, descricao, itens, data_servico, forma_pagamento, prazo_pagamento, observacoes }),
status: 'Rascunho',
created_by: currentUser?.id || null,
quote_request_supplier_id,
};
const { data, error } = await sb.from('purchase_orders').insert(payload).select().single();
if (error) { console.error('[Compras.criarOC] erro:', error); return null; }
setOcs(prev => [data, ...prev]);
return data;
};
// Chamado pela aba de Cotações quando a equipe escolhe o vencedor
const gerarOCdaCotacao = async ({ pedido, linha, fornecedor }) => {
const evento = events.find(e => e.id === pedido.event_id) || null;
const oc = await criarOC({
fornecedor,
evento,
descricao: [pedido.titulo, pedido.descricao].filter(Boolean).join('\n\n'),
itens: [{ descricao: pedido.titulo, qtd: 1, unidade: 'serviço', valor_unit: Number(linha.valor) || 0 }],
data_servico: pedido.evento_data || null,
forma_pagamento: 'PIX',
// prazo_pagamento fica em branco de propósito: o fornecedor informa
// prazo de ENTREGA na cotação, que é outra coisa. Quem define quando
// paga é a Promoup — a pessoa preenche antes de enviar a OC.
prazo_pagamento: null,
observacoes: [
linha.prazo_entrega ? `Prazo informado pelo fornecedor na cotação: ${linha.prazo_entrega}` : '',
linha.observacoes || '',
].filter(Boolean).join('\n') || null,
quote_request_supplier_id: linha.id,
});
if (oc) { trocarAba('ocs'); setAbertaId(oc.id); }
return oc;
};
// ---- ações sobre uma OC ----
const atualizar = async (id, patch) => {
const { data, error } = await sb.from('purchase_orders').update(patch).eq('id', id).select().single();
if (error) { console.error('[Compras.atualizar] erro:', error); return null; }
setOcs(prev => prev.map(o => o.id === data.id ? data : o));
return data;
};
// ---- correção de uma OC já criada ----
// Número, token, status e a conta a pagar não são tocados: o documento
// continua sendo o mesmo, só com o conteúdo certo. O pdf_url é zerado
// porque o arquivo que está no Storage virou a versão velha.
const editarOC = async (oc, dados) => {
if (!OC_EDITAVEL.includes(oc.status)) {
showToast(`${oc.numero} está ${oc.status} e não pode mais ser corrigida.`, 'danger');
return null;
}
const atualizada = await atualizar(oc.id, {
...conteudoOC(dados),
pdf_url: null,
updated_at: new Date().toISOString(),
});
if (!atualizada) { showToast('Não consegui salvar a correção.', 'danger'); return null; }
setEditandoId(null);
setAbertaId(atualizada.id);
showToast(
atualizada.status === 'Enviada'
? `${atualizada.numero} corrigida. Reenvie ao fornecedor para ele receber o PDF novo.`
: `${atualizada.numero} corrigida.`
);
return atualizada;
};
// Dá tempo do template invisível renderizar com a OC certa antes de capturar
const esperarTemplate = () => new Promise(r => setTimeout(r, 150));
const baixarPDF = async (oc) => {
setBusy('pdf'); await esperarTemplate();
await renderPDF(pdfRef.current, `${oc.numero}_${(oc.fornecedor_nome || '').replace(/[^\w]/g, '_')}.pdf`);
setBusy(null);
};
const enviarWhatsApp = async (oc) => {
const forn = fornecedores.find(f => f.id === oc.fornecedor_id);
if (!forn?.telefone) { showToast('Fornecedor sem telefone cadastrado. Use o e-mail ou copie o link.', 'danger'); return; }
setBusy('wpp');
await esperarTemplate();
// Sobe o PDF: a Z-API só entrega documento por URL pública
const blob = await ocPdfBlob(pdfRef.current);
const pdfUrl = blob ? await uploadDocPdf(blob, `${oc.numero}.pdf`) : null;
const r = await notifyFornecedorWhatsApp({
fornecedor: forn,
text: mensagemOC({ oc, link: publicDocUrl(oc.token) }),
mediaUrl: pdfUrl,
mediaFilename: `${oc.numero}.pdf`,
});
if (r.ok) {
await atualizar(oc.id, {
status: oc.status === 'Rascunho' ? 'Enviada' : oc.status,
sent_at: new Date().toISOString(), sent_via: 'whatsapp',
pdf_url: pdfUrl || oc.pdf_url,
});
}
setBusy(null);
showToast(
r.ok && !pdfUrl ? 'Mensagem enviada, mas o PDF não subiu — o fornecedor recebeu o link.' : r.motivo,
r.ok ? 'success' : 'danger'
);
};
const enviarEmail = async (oc) => {
if (!oc.fornecedor_email) { showToast('Fornecedor sem e-mail cadastrado.', 'danger'); return; }
setBusy('email'); await esperarTemplate();
// Baixa o PDF para a pessoa anexar: o sistema não tem servidor de e-mail
await renderPDF(pdfRef.current, `${oc.numero}.pdf`);
abrirEmail({
to: oc.fornecedor_email,
subject: `Ordem de Compra ${oc.numero} — Promoup`,
body: mensagemOC({ oc, link: publicDocUrl(oc.token) })
+ '\n\n(O PDF da OC foi baixado no seu computador — anexe nesta mensagem antes de enviar.)',
});
await atualizar(oc.id, {
status: oc.status === 'Rascunho' ? 'Enviada' : oc.status,
sent_at: new Date().toISOString(), sent_via: 'email',
});
setBusy(null);
showToast('E-mail aberto. Anexe o PDF que acabou de baixar e envie.');
};
const marcarEnviada = async (oc) => {
await atualizar(oc.id, { status: 'Enviada', sent_at: new Date().toISOString(), sent_via: 'manual' });
showToast('OC marcada como enviada. O fornecedor já pode responder pelo link.');
};
const cancelar = async (oc) => {
await atualizar(oc.id, { status: 'Cancelada' });
setCancelConfirm(null);
showToast(`${oc.numero} cancelada. O link do fornecedor deixou de valer.`);
};
const copiarLink = (oc) => {
navigator.clipboard?.writeText(publicDocUrl(oc.token));
showToast('Link copiado. Cole no WhatsApp ou e-mail do fornecedor.');
};
// ---- lista filtrada ----
const filtradas = ocs.filter(o => {
if (filtro !== 'todos' && o.status !== filtro) return false;
const q = busca.trim().toLowerCase();
if (!q) return true;
return [o.numero, o.fornecedor_nome, o.evento_nome, o.cliente_nome, o.descricao]
.some(v => (v || '').toLowerCase().includes(q));
});
const totais = {
abertas: ocs.filter(o => o.status === 'Enviada').length,
confirmadas: ocs.filter(o => o.status === 'Confirmada').length,
valorConfirmado: ocs.filter(o => o.status === 'Confirmada').reduce((s, o) => s + (Number(o.valor_total) || 0), 0),
};
const ABAS = [{ id: 'ocs', label: 'Ordens de Compra' }, { id: 'cotacoes', label: 'Cotações' }];
return (
Compras
Cotação com fornecedores, ordem de compra e conta a pagar — tudo registrado.
{aba === 'ocs' && setNovaModal(true)}>Nova Ordem de Compra}
))}
>
)}
>
)}
{/* O detalhe some enquanto o formulário de correção está aberto: dois
modais empilhados deixariam a tela confusa. Ao fechar a correção, o
detalhe volta sozinho, já com os dados novos. */}
setAbertaId(null)}
onEditar={(oc) => setEditandoId(oc.id)}
onPDF={baixarPDF}
onWhatsApp={enviarWhatsApp}
onEmail={enviarEmail}
onLink={copiarLink}
onMarcarEnviada={marcarEnviada}
onCancelar={(oc) => setCancelConfirm(oc)}
onVerFinanceiro={() => onNavigate?.('financeiro')}
/>
setCancelConfirm(null)} title="Cancelar ordem de compra" width={430}>
Cancelar a {cancelConfirm?.numero}? O link do fornecedor
para de funcionar na hora. Se ele já tinha confirmado, a conta a pagar continua no Financeiro e
precisa ser removida por lá.
{/* Mesmo formulário para criar e para corrigir — o que muda é receber
ou não uma OC em `oc`. */}
{ setNovaModal(false); setEditandoId(null); }}
onSubmit={async (dados) => {
if (editando) { await editarOC(editando, dados); return; }
const oc = await criarOC(dados);
if (!oc) { showToast('Não consegui criar a OC.', 'danger'); return; }
setNovaModal(false);
setAbertaId(oc.id);
showToast(`${oc.numero} criada. Revise e envie ao fornecedor.`);
}}
/>
{/* Template invisível que vira PDF */}
{toast && setToast(null)} />}