// 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) => (
{k}
{v || '—'}
); return (
{/* Cabeçalho */}
Promoup
Promoup Marketing & Eventos
Ordem de Compra
{oc.numero}
Emitida em {dataBR(oc.created_at)}
{/* Fornecedor / Cliente / Evento */}
{campo('Fornecedor', oc.fornecedor_nome)} {campo('Contato', [oc.fornecedor_contato, oc.fornecedor_telefone].filter(Boolean).join(' · '))}
{campo('Cliente', oc.cliente_nome)} {campo('Evento', oc.evento_nome)} {campo('Data do evento', dataBR(oc.evento_data))}
{/* Serviço */}
Serviço contratado
{oc.descricao && (
{oc.descricao}
)} {itens.length > 0 && ( {itens.map((it, i) => ( ))}
Descrição Qtd Valor unit. Subtotal
{it.descricao || '—'} {it.qtd || 0}{it.unidade ? ` ${it.unidade}` : ''} {fmtBRL(it.valor_unit)} {fmtBRL((Number(it.qtd) || 0) * (Number(it.valor_unit) || 0))}
)}
Valor total {fmtBRL(oc.valor_total)}
{/* Condições */}
{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) && (
Dados para pagamento
{campo('Favorecido', b.favorecido)} {campo('CPF/CNPJ', b.favorecido_doc)} {campo('Banco / Agência / Conta', [b.banco, b.agencia, b.conta && `${b.conta} (${b.tipo_conta || 'Corrente'})`].filter(Boolean).join(' · '))} {campo(`PIX${b.pix_tipo ? ` (${b.pix_tipo})` : ''}`, b.pix_chave)}
)} {oc.observacoes && (
Observações
{oc.observacoes}
)}
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}
{/* Abas */}
{ABAS.map(t => ( ))}
{aba === 'cotacoes' ? ( ) : loading ? : ( <>
{[ { label: 'Aguardando resposta', value: totais.abertas, color: DS.colors.primary }, { label: 'Confirmadas', value: totais.confirmadas, color: DS.colors.success }, { label: 'Valor confirmado', value: fmtBRL(totais.valorConfirmado), color: DS.colors.text }, ].map(s => (
{s.label}
{s.value}
))}
setBusca(e.target.value)} placeholder="Buscar por número, fornecedor, evento…" style={{ flex: 1, minWidth: 220, border: `1px solid ${DS.colors.border}`, borderRadius: DS.radius.sm, padding: '8px 12px', fontSize: 13, outline: 'none', fontFamily: 'inherit' }} />