/* global React, Icon, BRL, NUM, Ticker, Drawer */ const CONCORRENTES_PERFIL = { 'BIC LICITAÇÕES': { cnpj: '02.314.876/0001-22', uf: 'SP', cidade: 'São Paulo', porte: 'Médio porte', disputas30d: 47, vitorias30d: 18, hitRate: 38, marketShare: 12, categorias: ['Papelaria', 'Material Escritório', 'Escolar'], perfil: 'agressivo', ultVitoria: 'PE-085/2026 · UFMG · R$ 184k', confrontosBTM: { total: 32, ganhei: 19, perdi: 13 }, comportamento: [ 'Costuma dar lances agressivos nos últimos 30 segundos da rodada.', 'Em 73% das disputas com a BTM, abandona quando lance fica abaixo de R$ 21,40.', 'Foco em editais até R$ 500k — raramente disputa lotes maiores.', 'Histórico de inabilitação por documentação (2 ocorrências nos últimos 12 meses).', ], historico: [ { dt: '08/05/26', edital: 'PE-167/2026 · TJ-SP', vencedor: 'BIC', valor: 287000 }, { dt: '02/05/26', edital: 'PE-142/2026 · UFRJ', vencedor: 'BTM', valor: 412000 }, { dt: '28/04/26', edital: 'PE-098/2026 · Pref. SP', vencedor: 'BIC', valor: 168400 }, { dt: '22/04/26', edital: 'PE-051/2026 · IFRS', vencedor: 'BTM', valor: 92800 }, { dt: '14/04/26', edital: 'PE-088/2026 · Pref. Niterói', vencedor: 'BTM', valor: 132500 }, ], }, 'PAPELARIA NORDESTE': { cnpj: '14.870.331/0001-09', uf: 'CE', cidade: 'Fortaleza', porte: 'Pequeno porte (EPP)', disputas30d: 22, vitorias30d: 7, hitRate: 32, marketShare: 4, categorias: ['Papelaria', 'Escolar'], perfil: 'conservador', ultVitoria: 'PE-067/2026 · IFCE · R$ 87k', confrontosBTM: { total: 11, ganhei: 8, perdi: 3 }, comportamento: [ 'Lances iniciais conservadores · raramente desce abaixo de 5% do valor de referência.', 'Não costuma seguir até o fim em disputas longas (média de 4 lances por item).', 'Beneficia-se de tratamento ME/EPP em editais de até R$ 80k.', ], historico: [ { dt: '06/05/26', edital: 'PE-156/2026 · Pref. Fortaleza', vencedor: 'PAP. NORDESTE', valor: 78400 }, { dt: '29/04/26', edital: 'PE-098/2026 · UFC', vencedor: 'BTM', valor: 142000 }, { dt: '18/04/26', edital: 'PE-077/2026 · TJ-PE', vencedor: 'PAP. NORDESTE', valor: 56800 }, ], }, 'OFFICE SUL DISTRIB.': { cnpj: '07.452.118/0001-66', uf: 'PR', cidade: 'Curitiba', porte: 'Médio porte', disputas30d: 34, vitorias30d: 11, hitRate: 32, marketShare: 8, categorias: ['Material Escritório', 'Escolar', 'Limpeza'], perfil: 'oportunista', ultVitoria: 'PE-104/2026 · TJ-PR · R$ 218k', confrontosBTM: { total: 18, ganhei: 11, perdi: 7 }, comportamento: [ 'Atua principalmente nos estados do Sul (PR, SC, RS).', 'Sai do páreo se margem cair abaixo de 30% — pouca tolerância a guerra de preço.', 'Costuma cobrir 3 ou 4 itens estratégicos por edital, não disputa lote inteiro.', ], historico: [ { dt: '11/05/26', edital: 'PE-188/2026 · UFRGS', vencedor: 'OFFICE SUL', valor: 198400 }, { dt: '04/05/26', edital: 'PE-122/2026 · IFRS', vencedor: 'BTM', valor: 87000 }, { dt: '27/04/26', edital: 'PE-066/2026 · TJ-PR', vencedor: 'OFFICE SUL', valor: 256800 }, ], }, 'COMERCIAL OESTE': { cnpj: '23.114.998/0001-44', uf: 'GO', cidade: 'Goiânia', porte: 'Pequeno porte (EPP)', disputas30d: 18, vitorias30d: 5, hitRate: 28, marketShare: 3, categorias: ['Papelaria', 'Material Escritório'], perfil: 'recente', ultVitoria: 'PE-044/2026 · Pref. Goiânia · R$ 48k', confrontosBTM: { total: 6, ganhei: 5, perdi: 1 }, comportamento: [ 'Fornecedor recente no PNCP (cadastrado há 9 meses).', 'Lances tímidos · costuma desistir após o 3º lance.', 'Atua exclusivamente em editais do Centro-Oeste.', ], historico: [ { dt: '07/05/26', edital: 'PE-099/2026 · UFG', vencedor: 'BTM', valor: 124000 }, { dt: '25/04/26', edital: 'PE-044/2026 · Pref. Goiânia', vencedor: 'COMERCIAL O.', valor: 48000 }, ], }, }; function MDisputa({ onNav }) { const [disputas, setDisputas] = React.useState([]); const [editais, setEditais] = React.useState([]); const [loading, setLoading] = React.useState(true); const [activeAuctionId, setActiveAuctionId] = React.useState(null); // disputa.id (uuid) const [items, setItems] = React.useState([]); // edital_itens const [activeItemId, setActiveItemId] = React.useState(null); const [allLances, setAllLances] = React.useState([]); // lances da disputa atual const [fornecedor, setFornecedor] = React.useState(null); const [autoLance, setAutoLance] = React.useState(false); const [toast, setToast] = React.useState(null); const [showAddDisputa, setShowAddDisputa] = React.useState(false); const showToast = (msg, ms = 3500) => { setToast(msg); setTimeout(() => setToast(null), ms); }; const reloadDisputasEditais = React.useCallback(async () => { const [rows, eds] = await Promise.all([ window.dataApi.listDisputas(), window.dataApi.listEditais(), ]); setDisputas(rows); setEditais(eds); return { rows, eds }; }, []); // Carrega disputas + editais (para listar elegíveis no modal) React.useEffect(() => { let mounted = true; reloadDisputasEditais() .then(({ rows }) => { if (!mounted) return; if (rows.length > 0) setActiveAuctionId(rows[0].id); setLoading(false); }) .catch(() => { if (mounted) setLoading(false); }); return () => { mounted = false; }; }, [reloadDisputasEditais]); const handleAddDisputaFromEdital = async (editalId) => { try { const nova = await window.dataApi.insertDisputa({ edital_id: editalId }); try { await window.dataApi.updateEditalStatus(editalId, 'disputa'); } catch (_) { /* trigger histórico cuida */ } await reloadDisputasEditais(); setActiveAuctionId(nova.id); setShowAddDisputa(false); showToast(`${nova.edital?.numero || editalId} ativado como disputa · monitoramento iniciado`); } catch (e) { showToast(`Erro ao criar disputa: ${e?.message || e}`, 6000); } }; const handleAddDisputaManual = async (form) => { try { const novoEdital = await window.dataApi.insertEdital({ numero: form.id, orgao: form.orgao, uf: form.uf, plataforma: form.plataforma, valor: 0, abertura: form.abertura, itens: 0, status: 'disputa', score: 75, match: 'media', }); const nova = await window.dataApi.insertDisputa({ edital_id: novoEdital.id }); await reloadDisputasEditais(); setActiveAuctionId(nova.id); setShowAddDisputa(false); showToast(`Disputa ${form.id} cadastrada manualmente · aguardando abertura`); } catch (e) { showToast(`Erro ao cadastrar: ${e?.message || e}`, 6000); } }; // Carrega itens e lances quando muda de auction React.useEffect(() => { if (!activeAuctionId) return; const disp = disputas.find(d => d.id === activeAuctionId); if (!disp?.edital_id) return; let mounted = true; Promise.all([ window.dataApi.listEditalItens(disp.edital_id), window.dataApi.listLancesByDisputa(activeAuctionId), ]).then(([itensList, lancesList]) => { if (!mounted) return; setItems(itensList); setAllLances(lancesList); if (itensList.length > 0) setActiveItemId(itensList[0]._raw?.id || itensList[0].id); }).catch(() => { if (mounted) { setItems([]); setAllLances([]); } }); return () => { mounted = false; }; }, [activeAuctionId, disputas]); const disputa = disputas.find(d => d.id === activeAuctionId) || null; const editalItem = items.find(i => (i._raw?.id || i.id) === activeItemId) || items[0] || null; // Lances do item selecionado (já em ordem desc) const lancesDoItem = React.useMemo(() => { const eid = editalItem?._raw?.id || editalItem?.id; if (!eid) return []; return allLances.filter(l => l.edital_item_id === eid); }, [allLances, editalItem]); // Resumo de cada item (último lance, quem ganha) const itemSummary = React.useMemo(() => { const map = {}; items.forEach(it => { const eid = it._raw?.id || it.id; const meusItens = allLances.filter(l => l.edital_item_id === eid); const meu = meusItens.find(l => l.meu_lance); const melhor = meusItens.length > 0 ? Math.min(...meusItens.map(l => l.valor)) : null; const lider = meusItens.length > 0 ? meusItens.find(l => l.valor === melhor) : null; map[eid] = { meu: meu?.valor || null, melhor, ganhando: lider?.meu_lance || false }; }); return map; }, [items, allLances]); // Disputa header counts (derived) const dispHeader = React.useMemo(() => { if (!disputa || items.length === 0) return null; let ganhos = 0, perdidos = 0, disputando = 0; items.forEach(it => { const eid = it._raw?.id || it.id; const sum = itemSummary[eid]; if (!sum || sum.melhor === null) disputando++; else if (sum.ganhando) ganhos++; else perdidos++; }); return { id: disputa.id, edital_numero: disputa.edital?.numero, orgao: disputa.edital?.orgao, plataforma: disputa.edital?.plataforma, uf: disputa.edital?.uf, status: disputa.status, ganhos, perdidos, disputando, total: items.length, }; }, [disputa, items, itemSummary]); const enviarLance = async (valor) => { if (!valor || valor <= 0 || !editalItem || !activeAuctionId) return; try { const eid = editalItem._raw?.id || editalItem.id; const novoLance = await window.dataApi.insertLance({ disputa_id: activeAuctionId, edital_item_id: eid, valor: +valor.toFixed(2), autor: 'BTM', meu_lance: true, }); setAllLances(prev => [novoLance, ...prev]); showToast(`Lance R$ ${valor.toFixed(2)} enviado · BTM`); } catch (e) { showToast(`Erro ao enviar lance: ${e?.message || e}`, 5000); } }; // Auto-lance: a cada 8s React.useEffect(() => { if (!autoLance || !editalItem) return; const eid = editalItem._raw?.id || editalItem.id; const intv = setInterval(() => { const lances = allLances.filter(l => l.edital_item_id === eid); const editalUnit = parseFloat(editalItem._raw?.valor_unitario_estimado) || editalItem.unit || 0; const melhor = lances.length > 0 ? Math.min(...lances.map(l => l.valor)) : editalUnit; const minSeguro = editalUnit * 0.7; // proteção: 70% do valor de referência const proximo = +(melhor - 0.01).toFixed(2); if (proximo > minSeguro) { enviarLance(proximo); } else { setAutoLance(false); showToast('Auto-lance desativado · próximo lance ficaria abaixo do mínimo seguro (70% do edital)'); } }, 8000); return () => clearInterval(intv); }, [autoLance, editalItem, allLances]); return ( <> setShowAddDisputa(true)} loading={loading} itemSummaryMap={itemSummary} />
{editalItem ? ( { setAutoLance(v => !v); showToast(autoLance ? 'Auto-lance desativado' : 'Auto-lance ativado · cobertura a cada 8s respeitando o mínimo seguro'); }} /> ) : (
{loading ? <> Carregando... : 'Selecione uma disputa'}
)}
{fornecedor && setFornecedor(null)} />} {showAddDisputa && setShowAddDisputa(false)} editais={editais} disputasEditalIds={new Set(disputas.map(d => d.edital_id).filter(Boolean))} onPickElegivel={handleAddDisputaFromEdital} onSubmitManual={handleAddDisputaManual} />} {toast && (
{toast}
)} ); } /* ── AUCTION TABS ──────────────────────────────────────────── */ function AuctionTabs({ auctions, active, onSelect, onAddDisputa, loading }) { if (loading) { return
Carregando disputas...
; } if (auctions.length === 0) { return
Nenhuma disputa cadastrada.
; } return (
{auctions.map(a => { const isActive = a.id === active; const num = a.edital?.numero || '—'; const orgao = a.edital?.orgao || ''; const plataforma = a.edital?.plataforma || ''; const uf = a.edital?.uf || ''; return (
onSelect(a.id)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 16px', borderBottom: `2px solid ${isActive ? 'var(--lime)' : 'transparent'}`, marginBottom: -1, cursor: 'pointer', opacity: isActive ? 1 : 0.7, whiteSpace: 'nowrap', }}> {a.status === 'live' ? : }
{num} · {orgao}
{plataforma} · {uf} · {a.status.toUpperCase()}
); })}
); } /* ── ITEMS LIST ────────────────────────────────────────────── */ function ItemsList({ items, disputa, active, onSelect, itemSummary }) { return (
{disputa?.edital_numero || '—'} · {(disputa?.status || '').toUpperCase()}
{disputa?.orgao} · {disputa?.plataforma}
GANHANDO
{disputa?.ganhos ?? 0} / {disputa?.total ?? 0}
SEM LANCE
{disputa?.disputando ?? 0} / {disputa?.total ?? 0}
PERDENDO
{disputa?.perdidos ?? 0} / {disputa?.total ?? 0}
{items.length === 0 && (
Sem itens cadastrados para este edital.
)} {items.map(it => { const eid = it._raw?.id || it.id; const sum = itemSummary[eid]; return onSelect(eid)} />; })}
); } function ItemRow({ item, summary, isActive, onClick }) { let stripeColor = 'var(--fg-4)'; let label = 'SEM LANCE'; let labelColor = 'var(--fg-3)'; if (summary?.melhor !== null && summary?.melhor !== undefined) { if (summary.ganhando) { stripeColor = 'var(--lime)'; label = '✓ GANHANDO'; labelColor = 'var(--lime)'; } else { stripeColor = 'var(--red)'; label = 'PERDENDO'; labelColor = 'var(--red)'; } } return (
ITEM {String(item.numero_item || 0).padStart(2, '0')} {label}
{item.desc}
{NUM(item.qtd)} un · ref. {BRL(item.unit)}
{summary?.meu != null && (
seu: {BRL(summary.meu)} {summary.melhor !== null && melhor: {BRL(summary.melhor)}}
)}
); } /* ── CENTER DISPUTA ────────────────────────────────────────── */ function CenterDisputa({ item, lancesDoItem, summary, disputa, onFornecedor, onEnviarLance, autoLance, onToggleAuto }) { // View shape uniforme: traduz edital_item + summary em campos esperados pela UI const view = React.useMemo(() => { if (!item) return null; const eid = item._raw?.id || item.id; const editalUnit = parseFloat(item._raw?.valor_unitario_estimado) || item.unit || 0; return { id: eid, idx: item.numero_item || 0, desc: item.desc, cod: item.btm, ncm: item.ncm, qtd: item.qtd, edital: editalUnit, minSeguro: editalUnit * 0.7, melhor: summary?.melhor ?? editalUnit, meu: summary?.meu ?? null, mine: summary?.ganhando || false, }; }, [item, summary]); const [meuLance, setMeuLance] = React.useState(0); const [tick, setTick] = React.useState(0); React.useEffect(() => { if (view) setMeuLance(view.meu || view.melhor || view.edital); }, [view?.id, disputa?.id]); React.useEffect(() => { const i = setInterval(() => setTick(t => t + 1), 1000); return () => clearInterval(i); }, []); if (!view) return
Selecione um item à esquerda
; const isFechado = disputa?.status === 'encerrada'; const isLive = disputa?.status === 'live'; const ganho = isFechado && view.mine; const perdido = isFechado && !view.mine && view.melhor < view.edital; const concorrentes = useConcorrentes(view, disputa); return (
{/* Header item */}
ITEM {String(view.idx).padStart(2, '0')} · {isFechado ? (ganho ? 'GANHO' : 'PERDIDO') : view.melhor < view.edital && !view.mine ? 'PERDENDO' : isLive ? 'DISPUTA' : 'AGUARDANDO'} {isLive && <>LIVE}
{view.desc}
{NUM(view.qtd)} unidades · {view.cod || '—'} · NCM {view.ncm}
TEMPO DE LANCE: {isFechado ? '—' : isLive ? `00:${String(58 - (tick % 60)).padStart(2, '0')}` : 'aguardando'} · {lancesDoItem.length} lance(s) registrados
{/* Gráfico de lances */} {!isFechado && lancesDoItem.length > 0 && (
Histórico de lances
)} {/* Painel de lance */} {!isFechado && (
SEU PRÓXIMO LANCE · menor lance atual: R$ {(view.melhor || view.edital).toFixed(2)}
MARGEM: view.minSeguro ? 'var(--lime)' : 'var(--red)' }}>{meuLance > 0 ? (((meuLance - view.minSeguro) / meuLance) * 100).toFixed(1) : '0.0'}%
R$ setMeuLance(parseFloat(e.target.value) || 0)} style={{ background: 'var(--bg-1)', border: '1px solid var(--line-2)', borderRadius: 6, color: 'var(--lime)', fontSize: 32, fontWeight: 600, padding: '6px 14px', width: 180, outline: 'none', fontFamily: 'IBM Plex Mono' }} />
{meuLance < view.minSeguro && meuLance > 0 && (
ALERTA · ABAIXO DO PREÇO MÍNIMO SEGURO — abaixo de 70% do valor de referência do edital.
)}
)} {isFechado && (
Item {String(view.idx).padStart(2, '0')} {ganho ? 'adjudicado à BTM' : 'perdido'}
{ganho && view.meu ? `Lance vencedor: R$ ${view.meu.toFixed(2)} · Total ${BRL(view.meu * view.qtd)}` : view.melhor !== view.edital ? `Vencedor a R$ ${view.melhor.toFixed(2)}` : 'Sem disputa'}
)} {/* Histórico real de lances */}
Histórico de Lances · Item {String(view.idx).padStart(2, '0')}
{lancesDoItem.length} lance(s)
{lancesDoItem.length === 0 ? (
Sem lances registrados ainda. Use o painel acima pra enviar o primeiro.
) : ( {lancesDoItem .slice() .sort((a, b) => a.valor - b.valor) .map((l, idx) => { const isMelhor = idx === 0; return ( ); })}
Pos Autor Lance Total Hora
{idx + 1}º {l.autor}{l.meu_lance && (você)} {l.cnpj_autor &&
{l.cnpj_autor}
}
R$ {l.valor.toFixed(2)} {BRL(l.valor * view.qtd)} {new Date(l.ocorrido_em).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
)}
); } function useConcorrentes(view, disputa) { // mantido para compat — futuramente vem de tabela de concorrentes return []; } function BigStat({ label, value, color, emphasis }) { return (
{label}
R$ {value}
); } function BidChart({ view, lances }) { // ordena cronologicamente const sorted = [...lances].sort((a, b) => new Date(a.ocorrido_em) - new Date(b.ocorrido_em)); const meusLances = sorted.filter(l => l.meu_lance).map(l => l.valor); const outrosLances = sorted.filter(l => !l.meu_lance).map(l => l.valor); const edital = view.edital; const W = 600, H = 130; const allVals = [...sorted.map(l => l.valor), edital]; if (allVals.length < 2) { return
Poucos lances ainda · gráfico aparece a partir de 2 lances
; } const min = Math.min(...allVals) * 0.98; const max = Math.max(...allVals) * 1.02; const r = max - min || 1; const toPath = (arr) => arr.length > 1 ? arr.map((v, i) => `${i === 0 ? 'M' : 'L'} ${(i / (arr.length - 1)) * W} ${H - ((v - min) / r) * H}`).join(' ') : ''; return ( {[0, 25, 50, 75, 100].map(p => ( ))} EDITAL R$ {edital.toFixed(2)} {outrosLances.length > 1 && } {meusLances.length > 1 && } ); } /* ── CHAT PANEL ────────────────────────────────────────────── */ const TEMPLATES_CHAT = [ { l: 'Confirmar recebimento', t: 'Recebido, pregoeiro. Estamos analisando.' }, { l: 'Confirmar amostras', t: 'Confirmado, pregoeiro. Amostras serão enviadas ainda hoje via Sedex 10.' }, { l: 'Solicitar prorrogação', t: 'Solicitamos respeitosamente a prorrogação do prazo em 24h para apresentação de documento complementar.' }, { l: 'Disponível para esclarec.', t: 'Estamos à disposição para quaisquer esclarecimentos via chat ou pelo e-mail contato@btmsigma.com.' }, { l: 'Capacidade técnica', t: 'BTM atende integralmente a especificação técnica. Catálogo de produtos disponível para consulta na pasta do edital.' }, ]; function ChatPanel({ itemIdx, onToast }) { const [msgs, setMsgs] = React.useState([]); const [input, setInput] = React.useState(''); const [showTemplates, setShowTemplates] = React.useState(false); const [alertSonoro, setAlertSonoro] = React.useState(true); const fileInputRef = React.useRef(null); const scrollRef = React.useRef(null); // Chat real do pregoeiro virá das plataformas (PNCP/Comprasnet) via integração. // Por enquanto, mensagens locais apenas (digitadas pela Mariana). React.useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [msgs.length]); const enviar = () => { if (!input.trim()) return; const tm = new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); setMsgs([...msgs, { from: 'btm', t: input.trim(), tm }]); setInput(''); onToast && onToast('Mensagem enviada ao pregoeiro'); }; const handleKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); enviar(); } }; const handleAnexar = (e) => { const file = e.target.files?.[0]; if (!file) return; const tm = new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); setMsgs([...msgs, { from: 'btm', t: '', att: file.name, tm }]); onToast && onToast(`Anexo "${file.name}" enviado ao pregoeiro`); e.target.value = ''; }; const usarTemplate = (t) => { setInput(t.t); setShowTemplates(false); }; return (
Chat do Pregoeiro · Item {String(itemIdx).padStart(2, '0')}
{msgs.map((m, i) => )}
{showTemplates && (
TEMPLATES RÁPIDOS
{TEMPLATES_CHAT.map((tp, i) => (
usarTemplate(tp)} style={{ padding: '8px 10px', borderBottom: i < TEMPLATES_CHAT.length - 1 ? '1px solid var(--line-1)' : 'none', fontSize: 11.5 }}>
{tp.l}
"{tp.t}"
))}
)}