/* global React, Icon, BRL, NUM, ScoreDial, StatusPill, Section, Ticker, Drawer */ function MProspeccao({ onNav }) { const [editais, setEditais] = React.useState([]); const [loading, setLoading] = React.useState(true); const [loadError, setLoadError] = React.useState(null); const [selected, setSelected] = React.useState(null); const [drawerTab, setDrawerTab] = React.useState('visao'); const [showFilterModal, setShowFilterModal] = React.useState(false); const [showAddModal, setShowAddModal] = React.useState(false); const [refreshing, setRefreshing] = React.useState(false); const [toast, setToast] = React.useState(null); const [aderenciaMin, setAderenciaMin] = React.useState(70); const [ordenar, setOrdenar] = React.useState('score'); const reload = React.useCallback(async () => { try { setLoadError(null); const rows = await window.dataApi.listEditais(); setEditais(rows); } catch (e) { setLoadError(e?.message || 'Falha ao carregar editais'); } finally { setLoading(false); } }, []); React.useEffect(() => { reload(); }, [reload]); const showToast = (msg, ms = 4000) => { setToast(msg); setTimeout(() => setToast(null), ms); }; const editaisFiltrados = React.useMemo(() => { const filtrados = editais.filter(e => (e.score ?? 0) >= aderenciaMin); const ord = [...filtrados]; if (ordenar === 'score') ord.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); else if (ordenar === 'valor') ord.sort((a, b) => b.valor - a.valor); else if (ordenar === 'prazo') ord.sort((a, b) => new Date(a.abertura) - new Date(b.abertura)); return ord; }, [editais, aderenciaMin, ordenar]); const sel = selected ? editais.find(e => e.id === selected) : null; const openEdital = (id) => { setSelected(id); setDrawerTab('visao'); }; const closeDrawer = () => setSelected(null); const handleRefresh = async () => { if (refreshing) return; setRefreshing(true); await reload(); setRefreshing(false); showToast('Lista de editais atualizada do banco · varredura PNCP automática ainda mock'); }; const handleAdded = async (form) => { try { const created = await window.dataApi.insertEdital(form); await reload(); setShowAddModal(false); showToast(`Edital ${created.numero} adicionado · persistido no Supabase`); } catch (e) { const msg = e?.message || String(e); if (/duplicate key/i.test(msg) || /unique/i.test(msg)) { showToast(`Erro: já existe um edital com o número informado.`, 6000); } else { showToast(`Erro ao salvar: ${msg}`, 6000); } } }; const handleDescartar = async (edital) => { try { await window.dataApi.updateEditalStatus(edital.id, 'descartado'); await reload(); closeDrawer(); showToast(`Edital ${edital.numero} marcado como DESCARTADO`); } catch (e) { showToast(`Erro ao descartar: ${e?.message || e}`, 6000); } }; return ( <>
Prospecção AutomáticaM01

Editais aderentes ao seu portfólio

Varredura contínua em PNCP, Comprasnet, LICITANET, AMMLICITA e LICITAR — cruzamento automático com NCM e descrição dos produtos da BTM.

UF: RJ, SP, ES, MG, SC, PR, RS
Ramo: Mat. Escritório · Papelaria · Escolar
Valor mín: R$ 50.000
+ Adicionar filtro
Aderência mínima
{[60, 70, 80].map(v => (
setAderenciaMin(v)}>{v}%
))}
{loadError && (
Falha ao carregar editais do Supabase: {loadError}
)} {/* Lista de editais — largura total */}
{loading ? 'Carregando editais...' : `${editaisFiltrados.length} editais encontrados`} {!loading && editaisFiltrados.length !== editais.length && (de {editais.length})}
{loading ? '...' : `${editais.length} registros no banco`}
Ordenar:
{[ { id: 'score', l: 'SCORE' }, { id: 'valor', l: 'VALOR' }, { id: 'prazo', l: 'PRAZO' }, ].map(o => (
setOrdenar(o.id)}>{o.l}
))}
{loading && ( )} {!loading && editaisFiltrados.length === 0 && ( )} {!loading && editaisFiltrados.map(e => ( openEdital(e.id)} style={{ cursor: 'pointer' }}> ))}
Edital Órgão / Plataforma UF Abertura Itens Valor estim. Status
Buscando editais no Supabase...
{editais.length === 0 ? 'Nenhum edital cadastrado ainda. Use "Adicionar manual" para começar.' : `Nenhum edital com aderência ≥ ${aderenciaMin}%. Tente baixar o filtro.`}
{e.numero}
{e.orgao}
{e.plataforma}
{e.uf}
{new Date(e.abertura).toLocaleDateString('pt-BR')}
{new Date(e.abertura).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
{e.itens} {BRL(e.valor)}
{showFilterModal && setShowFilterModal(false)} />} {showAddModal && setShowAddModal(false)} onAdd={handleAdded} />} {sel && } {toast && (
{toast}
)} ); } function AddEditalModal({ onClose, onAdd }) { const [form, setForm] = React.useState({ numero: 'PE-' + String(Math.floor(Math.random() * 900) + 100) + '/2026', orgao: '', plataforma: 'PNCP', uf: 'RJ', cidade: '', valor: '', abertura: '', itens: '', verba: '', }); const [erro, setErro] = React.useState(null); const [saving, setSaving] = React.useState(false); const set = (k, v) => setForm({ ...form, [k]: v }); const submit = async () => { if (!form.orgao || !form.valor || !form.abertura || !form.itens) { setErro('Preencha os campos obrigatórios (Órgão, Valor, Abertura, Itens)'); return; } setErro(null); setSaving(true); try { await onAdd({ ...form, valor: parseFloat(form.valor), itens: parseInt(form.itens), status: 'novo', score: 75, match: 'media' }); } finally { setSaving(false); } }; return (
e.stopPropagation()}>
Adicionar edital manualmente
não capturado por varredura automática
Quando usar isso
Para editais recebidos por canal não-PNCP (e-mail direto, indicação, portais não integrados). Após salvar, a IA fará cruzamento automático com NCMs cadastrados.
NÚMERO DO EDITAL *
set('numero', e.target.value)} placeholder="PE-187/2026" />
PLATAFORMA *
ÓRGÃO *
set('orgao', e.target.value)} placeholder="Ex: Prefeitura Municipal de Niterói" />
CIDADE
set('cidade', e.target.value)} placeholder="Ex: Niterói" />
UF *
VALOR ESTIMADO (R$) *
set('valor', e.target.value)} placeholder="482350" />
QUANTIDADE DE ITENS *
set('itens', e.target.value)} placeholder="47" />
DATA/HORA DE ABERTURA *
set('abertura', e.target.value)} />
VERBA / FONTE (opcional)
set('verba', e.target.value)} placeholder="PMS-NIT" />
{erro && (
{erro}
)}
); } /* ── EDITAL DRAWER ─────────────────────────────────────────── */ function EditalDrawer({ edital, onClose, tab, onTab, onNav, onDescartar }) { const [descarting, setDescarting] = React.useState(false); const handleDescartar = async () => { if (descarting) return; setDescarting(true); try { await onDescartar?.(edital); } finally { setDescarting(false); } }; const [itens, setItens] = React.useState(null); const [mensagens, setMensagens] = React.useState(null); const [historico, setHistorico] = React.useState(null); const [documentos, setDocumentos] = React.useState(null); const reloadAll = React.useCallback(async () => { if (!edital?.id) return; try { const [i, d, m, h] = await Promise.all([ window.dataApi.listEditalItens(edital.id), window.dataApi.listEditalDocumentos(edital.id), window.dataApi.listEditalMensagens(edital.id), window.dataApi.listEditalHistorico(edital.id), ]); setItens(i); setDocumentos(d); setMensagens(m); setHistorico(h); } catch (err) { setItens([]); setDocumentos([]); setMensagens([]); setHistorico([]); } }, [edital?.id]); React.useEffect(() => { reloadAll(); }, [reloadAll]); const reloadMensagens = React.useCallback(async () => { if (!edital?.id) return; try { setMensagens(await window.dataApi.listEditalMensagens(edital.id)); } catch {} }, [edital?.id]); return ( {edital.plataforma}M01} title={{edital.numero} · {edital.orgao}} subtitle={`${edital.cidade || '—'} · ${edital.uf} · ${edital.itens} itens · ${BRL(edital.valor)}`} headRight={
ADERÊNCIA {(edital.match || 'media').toUpperCase()}
} tabs={[ { id: 'visao', label: 'Visão geral' }, { id: 'itens', label: 'Itens', count: itens?.length ?? '…' }, { id: 'docs', label: 'Documentos', count: documentos?.length ?? '…' }, { id: 'hist', label: 'Histórico', count: historico?.length ?? '…' }, { id: 'msg', label: 'Mensagens', count: mensagens?.length ?? '…' }, ]} activeTab={tab} onTabChange={onTab} footer={ edital.status === 'descartado' ? (
Edital descartado. Para reativar, mude o status pela tela de edição.
) : ( <> ) } > {tab === 'visao' && } {tab === 'itens' && } {tab === 'docs' && } {tab === 'hist' && } {tab === 'msg' && }
); } /* ── TAB CONTENT ──────────────────────────────────────────── */ function TabVisaoGeral({ edital, itens }) { const list = itens || []; const cobertos = list.filter(i => i.match >= 70).length; const margem = list.length > 0 ? (list.filter(i => i.match >= 70).reduce((a, i) => a + i.margem, 0) / Math.max(1, cobertos)).toFixed(1) : '0.0'; return (
DADOS PRINCIPAIS
CRUZAMENTO BTM × EDITAL
{itens === null ? (
Carregando itens...
) : list.length === 0 ? (
Nenhum item cadastrado para este edital.
) : ( <>
{list.slice(0, 6).map((it, i) => 0 ? Math.ceil(it.estoque / 80) : 0} alert={it.match > 0 && it.match < 90} miss={it.match === 0} />)}
{cobertos} de {list.length} itens cobertos · margem projetada {margem}%
)}
SCORE E ADERÊNCIA
); } function TabItens({ itens }) { return (
{itens === null && ( )} {itens && itens.length === 0 && ( )} {(itens || []).map((it, i) => ( ))}
# Descrição NCM Qtd. Vlr unit. Total Match BTM
Carregando itens...
Nenhum item cadastrado para este edital.
{String(it.numero_item ?? (i + 1)).padStart(2, '0')}
{it.desc}
{it.btm &&
BTM: {it.btm}
}
{it.ncm} {it.qtd} {BRL(it.unit)} {BRL(it.qtd * it.unit)} {it.match === 0 ? ( SEM MATCH ) : (
= 90 ? 'var(--lime)' : it.match >= 70 ? 'var(--amber)' : 'var(--red)' }}>
{it.match}%
)}
); } function TabDocumentos({ documentos }) { if (documentos === null) { return
Carregando documentos...
; } return (
ANEXOS DO EDITAL ({documentos.length})
{documentos.length === 0 ? (
Nenhum documento cadastrado para este edital.
) : (
{documentos.map((d) => (
{(d.ext || '?').toUpperCase()}
{d.nome}
{d.tamanho} · publicado {d.publicado}
{d.paginas ? d.paginas + ' págs' : '—'}
))}
)}
Upload de arquivos será habilitado quando o Storage do Supabase for configurado (fase posterior).
); } function TabHistorico({ historico }) { if (historico === null) { return
Carregando histórico...
; } if (historico.length === 0) { return
Nenhum evento registrado para este edital.
; } return (
{historico.map((h) => (
{h.titulo}
{h.quando}
{h.descricao &&
{h.descricao}
}
))}
); } function TabMensagens({ mensagens, editalId, onReload }) { const [showForm, setShowForm] = React.useState(false); const [assunto, setAssunto] = React.useState(''); const [corpo, setCorpo] = React.useState(''); const [saving, setSaving] = React.useState(false); const [erro, setErro] = React.useState(null); const submit = async () => { if (!assunto.trim() || !corpo.trim()) { setErro('Preencha assunto e mensagem.'); return; } setErro(null); setSaving(true); try { await window.dataApi.insertEditalMensagem(editalId, { tipo: 'interna', assunto, corpo }); await onReload?.(); setAssunto(''); setCorpo(''); setShowForm(false); } catch (e) { setErro(e?.message || 'Falha ao salvar.'); } finally { setSaving(false); } }; if (mensagens === null) { return
Carregando mensagens...
; } return (
{mensagens.length === 0 && !showForm && (
Nenhuma mensagem registrada.
)} {mensagens.map((m) => (
{m.de} {m.tipo === 'oficial' ? 'OFICIAL' : m.tipo === 'esclarecimento' ? 'ESCLARECIMENTO' : 'INTERNA'} {m.quando}
{m.assunto}
{m.corpo}
))}
{!showForm ? ( ) : (
NOVA ANOTAÇÃO INTERNA
setAssunto(e.target.value)} autoFocus />