// Tela: Oportunidades — pipeline + tabela / kanban const { useState: useOpState } = React; /* ─── Modal criar / editar oportunidade ─────────────────── */ function OportunidadeModal({ stages, onClose, onCreate, onUpdate, initialData }) { const isEdit = !!initialData; const [conta, setConta] = useOpState(initialData?.conta || ''); const [valor, setValor] = useOpState(initialData?.valor ? String(initialData.valor) : ''); const [etapa, setEtapa] = useOpState(initialData?.etapa || 'Qualificado'); const [prob, setProb] = useOpState(initialData?.prob ?? 50); const [fecha, setFecha] = useOpState(initialData?.fecha || ''); const toast = useToast(); const valid = conta.trim().length > 0; const submit = () => { if (!valid) return; const dados = { conta: conta.trim(), valor: parseInt(String(valor).replace(/\D/g,'')) || 0, etapa, prob, fecha: fecha || 'A definir', dono: initialData?.dono || CONTATOS[0], }; if (isEdit) { if (onUpdate) onUpdate(dados); toast(`"${conta.trim()}" atualizada ✓`); } else { if (onCreate) onCreate({ ...dados, id: Date.now() }); toast(`Oportunidade "${conta.trim()}" criada ✓`); } onClose(); }; return (
e.stopPropagation()}>
{isEdit ? <>Editar Oportunidade : <>Nova Oportunidade}
{isEdit ? `Editando: ${initialData.conta}` : 'Adicione um negócio ao pipeline'}
{ICN.x}
setConta(e.target.value)} autoFocus onKeyDown={e => e.key === 'Enter' && submit()} />
R$ setValor(e.target.value)} style={{border:0,outline:0,flex:1,fontFamily:'inherit',fontSize:13,background:'transparent'}} />
setProb(+e.target.value)} className="op-range" />
setFecha(e.target.value)} />
); } /* ─── Modal atribuir responsável ────────────────────────── */ function AtribuirOpModal({ op, onClose, onAtribuir }) { const [sel, setSel] = useOpState(op.dono?.id ?? 0); const toast = useToast(); const submit = () => { const c = CONTATOS.find(ct => ct.id === sel) || CONTATOS[0]; onAtribuir(c); toast(`Atribuído para ${c.nome.split(' ')[0]} ✓`); onClose(); }; return (
e.stopPropagation()} style={{width: 440}}>
Atribuir responsável
{op.conta}
{ICN.x}
Selecionar responsável
{CONTATOS.slice(0, 8).map(ct => (
setSel(ct.id)} style={{ display:'flex', alignItems:'center', gap:12, padding:'10px 12px', borderRadius:12, cursor:'default', background: sel === ct.id ? 'oklch(0.95 0.05 250 / 0.5)' : 'transparent', }}>
{ct.nome.split(' ').slice(0,2).join(' ')}
{ct.cargo}
{sel === ct.id && {ICN.check}}
))}
); } /* ─── Modal reagendar fechamento ────────────────────────── */ function ReagendarModal({ op, onClose, onSave }) { const [data, setData] = useOpState(''); const toast = useToast(); const valid = data.trim().length > 0; const submit = () => { onSave(data); toast(`Fechamento reagendado para ${data} ✓`); onClose(); }; return (
e.stopPropagation()} style={{width: 400}}>
Reagendar fechamento
{op.conta} · atual: {op.fecha}
{ICN.x}
setData(e.target.value)} autoFocus onKeyDown={e => e.key === 'Enter' && valid && submit()} />
); } const opHeaders = () => { const t = localStorage.getItem('ezza_token'); return { 'Content-Type':'application/json', ...(t?{'Authorization':`Bearer ${t}`}:{}) }; }; function Oportunidades({ goto, onOpenContact }) { const stages = ["Qualificado", "Proposta", "Negociação", "Fechado"]; const [localOps, setLocalOps] = useOpState([...OPORTUNIDADES]); React.useEffect(() => { fetch('/api/oportunidades', { headers: opHeaders() }) .then(r => r.json()) .then(d => { if (d.ok && d.oportunidades.length) setLocalOps(d.oportunidades.map(o => ({ ...o, valor: parseFloat(o.valor)||0, dono: { nome: o.dono_nome || 'Equipe', avatar: AVATAR_URLS[0] } }))); }) .catch(() => {}); }, []); const [view, setView] = useOpState('lista'); const [stageFilter, setStageFilter] = useOpState(null); const [sort, setSort] = useOpState({ col: 'valor', dir: 'desc' }); const [filterOpen, setFilterOpen] = useOpState(false); const [exportOpen, setExportOpen] = useOpState(false); const [novoOpen, setNovoOpen] = useOpState(false); const [editFor, setEditFor] = useOpState(null); const [atribuirFor, setAtribuirFor] = useOpState(null); const [reagendarFor, setReagendarFor] = useOpState(null); const [probMin, setProbMin] = useOpState(0); const [valMin, setValMin] = useOpState(0); const [donoFilter, setDonoFilter] = useOpState('Todos'); const toast = useToast(); // Fecha painéis flutuantes ao clicar fora (setTimeout evita que o próprio clique que abriu feche imediatamente) React.useEffect(() => { if (!exportOpen && !filterOpen) return; const h = () => { setExportOpen(false); setFilterOpen(false); }; const timer = setTimeout(() => window.addEventListener('click', h), 0); return () => { clearTimeout(timer); window.removeEventListener('click', h); }; }, [exportOpen, filterOpen]); const donos = ['Todos', ...new Set(localOps.map(o => o.dono.nome.split(' ').slice(0,2).join(' ')))]; let filtradas = localOps.filter(o => { if (stageFilter && o.etapa !== stageFilter) return false; if (o.prob < probMin) return false; if (o.valor < valMin) return false; if (donoFilter !== 'Todos' && o.dono.nome.split(' ').slice(0,2).join(' ') !== donoFilter) return false; return true; }); filtradas = [...filtradas].sort((a, b) => { const dir = sort.dir === 'asc' ? 1 : -1; if (sort.col === 'conta') return dir * a.conta.localeCompare(b.conta); if (sort.col === 'valor') return dir * (a.valor - b.valor); if (sort.col === 'etapa') return dir * stages.indexOf(a.etapa) - stages.indexOf(b.etapa) * dir + (a.valor - b.valor) * 0; // simple if (sort.col === 'prob') return dir * (a.prob - b.prob); if (sort.col === 'fecha') return dir * a.fecha.localeCompare(b.fecha); return 0; }); const totalsByStage = stages.map(s => localOps.filter(o => o.etapa === s).reduce((sum, o) => sum + o.valor, 0)); const grandTotal = localOps.reduce((s, o) => s + o.valor, 0); const totalFiltrado = filtradas.reduce((s, o) => s + o.valor, 0); const filtrosAtivos = (stageFilter ? 1 : 0) + (probMin > 0 ? 1 : 0) + (valMin > 0 ? 1 : 0) + (donoFilter !== 'Todos' ? 1 : 0); const exportOps = (format) => { setExportOpen(false); const rows = filtradas; const header = ['Conta', 'Valor (R$)', 'Etapa', 'Probabilidade (%)', 'Fecha em', 'Responsável']; const data = rows.map(o => [ o.conta, o.valor, o.etapa, o.prob, o.fecha, o.dono.nome, ]); if (format === 'csv') { const bom = ''; const csv = bom + [header, ...data].map(r => r.map(v => `"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n'); const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(new Blob([csv], {type:'text/csv;charset=utf-8'})), download: 'oportunidades.csv' }); a.click(); URL.revokeObjectURL(a.href); toast('CSV baixado ✓'); } else if (format === 'xls') { const tbl = `${header.map(h=>``).join('')}${data.map(r=>`${r.map(c=>``).join('')}`).join('')}
${h}
${c}
`; const xls = `${tbl}`; const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(new Blob([xls], {type:'application/vnd.ms-excel'})), download: 'oportunidades.xls' }); a.click(); URL.revokeObjectURL(a.href); toast('Excel baixado ✓'); } else if (format === 'pdf') { const w = window.open('', '_blank'); w.document.write(`Oportunidades

Oportunidades em andamento

${rows.length} negócios · exportado em ${new Date().toLocaleDateString('pt-BR')}

${header.map(h=>``).join('')} ${data.map(r=>`${r.map((c,i)=>`${i===1?'R$ '+Number(c).toLocaleString('pt-BR'):c}`).join('')}`).join('')}
${h}
`); w.document.close(); setTimeout(() => { w.focus(); w.print(); }, 400); toast('PDF abrindo para impressão ✓'); } else if (format === 'email') { const subject = encodeURIComponent(`Oportunidades Ezza CRM — ${new Date().toLocaleDateString('pt-BR')}`); const body = encodeURIComponent(`Segue o resumo das ${rows.length} oportunidades:\n\n` + rows.map(o => `• ${o.conta} — R$ ${o.valor.toLocaleString('pt-BR')} (${o.etapa}, ${o.prob}%, fecha ${o.fecha})`).join('\n')); window.open(`mailto:leticia.andrade@ezza.com.br?subject=${subject}&body=${body}`); toast('Abrindo e-mail ✓'); } }; return ( <> Oportunidades em andamento} subtitle={`${filtradas.length} negócios ativos · pipeline filtrado R$ ${(totalFiltrado/1000).toFixed(0)}k`} right={
Meta do trimestre 78%
{filterOpen && (
e.stopPropagation()}>
Filtros
{filtrosAtivos > 0 && ( )}
Etapa
{stages.map(s => ( setStageFilter(stageFilter === s ? null : s)}> {s} ))}
Probabilidade mínima · {probMin}%
setProbMin(+e.target.value)} className="op-range" />
Valor mínimo · R$ {(valMin/1000).toFixed(0)}k
setValMin(+e.target.value)} className="op-range" />
Responsável
{donos.map(d => ( setDonoFilter(d)}> {d} ))}
)}
{exportOpen && (
e.stopPropagation()}>
Exportar
{filtradas.length} registros
exportOps('pdf')}>
PDF
Relatório em PDF
Tabela para impressão · {filtradas.length} registros
{ICN.arrowRight}
exportOps('csv')}>
CSV
Dados em CSV
Para importar em outro CRM · {filtradas.length} linhas
{ICN.arrowRight}
exportOps('xls')}>
XLS
Planilha do Excel
{filtradas.length} oportunidades em .xls
{ICN.arrowRight}
exportOps('email')}>
{ICN.mail}
Enviar por e-mail
leticia.andrade@ezza.com.br
{ICN.arrowRight}
)}
} />
{stages.map((s, i) => { const active = stageFilter === s; return (
setStageFilter(active ? null : s)}>
{s}
{localOps.filter(o => o.etapa === s).length} negócios
R$ {(totalsByStage[i] / 1000).toFixed(0)}k
{active &&
{ICN.check} filtro ativo
}
); })}
{stageFilter ? `Oportunidades · ${stageFilter}` : 'Todas as Oportunidades'} ({filtradas.length})
{view === 'lista' ? ( ContaValorEtapaProb.Fecha em {filtradas.map(o => { const stagePill = { Qualificado: 'pill-blue', Proposta: 'pill-yellow', Negociação: 'pill-pink', Fechado: 'pill-mint' }[o.etapa]; return ( ); })} {filtradas.length === 0 && ( )}
Responsável
{o.conta} R$ {o.valor.toLocaleString('pt-BR')} {o.etapa}
{o.prob}%
{o.fecha}
onOpenContact && onOpenContact(o.dono.id)} style={{cursor: onOpenContact ? 'pointer' : 'default'}}> { if(onOpenContact) e.currentTarget.style.transform='scale(1.15)'; }} onMouseLeave={e => e.currentTarget.style.transform=''} alt="" /> {o.dono.nome.split(' ').slice(0,2).join(' ')}
{ setLocalOps(prev => prev.map(x => x.id===o.id ? {...x, etapa:'Fechado'} : x)); toast(`"${o.conta}" marcada como ganha ✓`); }, }, { ic: ICN.x, label: 'Marcar como perdida', danger: true, onClick: () => { setLocalOps(prev => prev.filter(x => x.id !== o.id)); toast(`"${o.conta}" marcada como perdida`); }, }, { ic: ICN.arrowRight, label: 'Avançar etapa', onClick: () => { const idx = stages.indexOf(o.etapa); const next = stages[Math.min(idx+1, stages.length-1)]; setLocalOps(prev => prev.map(x => x.id===o.id ? {...x, etapa:next} : x)); toast(`Etapa → "${next}"`); }, }, { divider: true }, { ic: ICN.edit, label: 'Editar negócio', onClick: () => setEditFor(o) }, { ic: ICN.users, label: 'Atribuir a alguém', onClick: () => setAtribuirFor(o) }, { ic: ICN.cal, label: 'Reagendar fechamento', onClick: () => setReagendarFor(o) }, { ic: ICN.share, label: 'Compartilhar link', onClick: () => { navigator.clipboard?.writeText(`https://app.ezza.com.br/oportunidades/${o.id}`).catch(()=>{}); toast('Link copiado ✓'); } }, { divider: true }, { ic: ICN.trash, label: 'Excluir negócio', danger: true, onClick: () => { setLocalOps(prev => prev.filter(x => x.id !== o.id)); toast(`"${o.conta}" excluída`); }, }, ]} />
Nenhuma oportunidade bate com os filtros.
) : (
{stages.map((s, i) => { const cards = filtradas.filter(o => o.etapa === s); const colColor = ['blue','yellow','pink','mint'][i]; return (
{s} {cards.length}
R$ {(cards.reduce((s,o) => s+o.valor, 0)/1000).toFixed(0)}k
{cards.map(o => (
{o.conta}
R$ {o.valor.toLocaleString('pt-BR')}
{o.prob}% · fecha {o.fecha}
))} {cards.length === 0 &&
Vazio
}
); })}
)}
{novoOpen && ( setNovoOpen(false)} onCreate={(nova) => setLocalOps(prev => [{ ...nova, id: Date.now() }, ...prev])} /> )} {editFor && ( setEditFor(null)} onUpdate={(dados) => { setLocalOps(prev => prev.map(x => x.id === editFor.id ? { ...x, ...dados } : x)); setEditFor(null); }} /> )} {atribuirFor && ( setAtribuirFor(null)} onAtribuir={(c) => { setLocalOps(prev => prev.map(x => x.id === atribuirFor.id ? { ...x, dono: c } : x)); setAtribuirFor(null); }} /> )} {reagendarFor && ( setReagendarFor(null)} onSave={(data) => { setLocalOps(prev => prev.map(x => x.id === reagendarFor.id ? { ...x, fecha: data } : x)); setReagendarFor(null); }} /> )} ); } function SortTh({ col, sort, setSort, children }) { const active = sort.col === col; return ( setSort({ col, dir: active && sort.dir === 'asc' ? 'desc' : (active && sort.dir === 'desc' ? 'asc' : 'desc') })}> {children} {active && sort.dir === 'asc' ? ICN.arrowUp : } ); } function Field({ label, full, children }) { return (
{label}
{children}
); } function SelectMockOp({ value, muted }) { return (
{value} {ICN.chevron}
); } window.Oportunidades = Oportunidades;