/* ============================================================
KeS Metas — CRM (Funil de Vendas / Kanban)
============================================================ */
const { useState, useRef } = React;
const ORIGENS = ["Instagram", "LinkedIn", "WhatsApp", "Indicação", "Site", "Outbound", "Evento", "Outro"];
// Ordem das colunas no CSV de import/export (modelo da planilha).
const CSV_COLS = ["nome", "empresa", "socios", "porte", "telefone", "telefone2", "telefone3", "email", "endereco", "instagram", "linkedin", "google", "origem", "valor", "responsavel", "tags", "observacoes", "etapa"];
function csvExampleRows(viewer, stages) {
return [
{ nome: "Maria Souza", empresa: "Loja Exemplo", socios: "Carlos Andrade; Marina Costa", porte: "Pequena", telefone: "(54) 9 9999-0000", telefone2: "(54) 3221-0000", telefone3: "", email: "maria@exemplo.com.br", endereco: "Av. Brasil, 1200 — Caxias do Sul/RS", instagram: "@lojaexemplo", linkedin: "linkedin.com/company/lojaexemplo", google: "g.page/lojaexemplo", origem: "Instagram", valor: "8500", responsavel: viewer.email, tags: "Quente;Inbound", observacoes: "Pediu proposta", etapa: stages[0].nome },
{ nome: "João Lima", empresa: "Empresa XYZ", socios: "Roberto Lima", porte: "Média", telefone: "(54) 9 8888-1111", telefone2: "", telefone3: "", email: "joao@xyz.com.br", endereco: "Rua Os Dezoito do Forte, 800 — Bento Gonçalves/RS", instagram: "@empresaxyz", linkedin: "linkedin.com/company/xyz", google: "xyz.com.br", origem: "Indicação", valor: "15000", responsavel: viewer.email, tags: "Morno", observacoes: "", etapa: stages[1] ? stages[1].nome : stages[0].nome },
];
}
function tagClass(t) {
const k = t.toLowerCase();
if (k === "quente") return "tag t-quente";
if (k === "morno") return "tag t-morno";
if (k === "frio") return "tag t-frio";
return "tag";
}
function stageColor(tipo) { return tipo === "won" ? "var(--ok)" : tipo === "lost" ? "var(--kes-red)" : "var(--info)"; }
function parseMoney(s) {
if (typeof s === "number") return s;
const n = String(s).replace(/[^\d,.-]/g, "").replace(/\.(?=\d{3}(\D|$))/g, "").replace(",", ".");
return Math.max(0, Math.round(parseFloat(n) || 0));
}
/* ---------------- Card de lead ---------------- */
function LeadCard({ lead, user, onOpen, onAgendar, onDragStart, onDragEnd, dragging }) {
const K = window.KES;
return (
onDragStart(e, lead)} onDragEnd={onDragEnd} onClick={onOpen}>
{lead.nome}
{lead.empresa}
{lead.valor ? K.fmtMoeda(lead.valor) : "—"}
{lead.tags && lead.tags.length > 0 && (
{lead.tags.map((t) => {t} )}
)}
{lead.origem}{lead.porte ? ` · ${lead.porte}` : ""}
e.stopPropagation()}>
{user &&
}
);
}
/* ---------------- Coluna do funil ---------------- */
function KanbanColumn({ stage, leads, usersById, onDropLead, onOpen, onAgendar, drag }) {
const K = window.KES;
const [over, setOver] = useState(false);
const total = leads.reduce((t, l) => t + (l.valor || 0), 0);
return (
{ e.preventDefault(); setOver(true); }}
onDragLeave={() => setOver(false)}
onDrop={(e) => { e.preventDefault(); setOver(false); onDropLead(stage.id); }}>
{stage.nome}
{leads.length}
{K.fmtMoeda(total)}
{leads.length === 0 ? (
Arraste leads para cá
) : leads.map((l) => (
onOpen(l)} onAgendar={() => onAgendar(l)}
onDragStart={drag.start} onDragEnd={drag.end} />
))}
);
}
/* ---------------- Modal de lead (cadastro completo) ---------------- */
function LeadModal({ initial, stages, users, viewer, onClose, onSave, onDelete, onAgendar }) {
const editing = !!(initial && initial.id);
const isAdmin = viewer.papel === "admin";
const [f, setF] = useState(initial || {
nome: "", empresa: "", telefone: "", telefone2: "", telefone3: "", email: "", socios: "",
instagram: "", google: "", linkedin: "", porte: "", endereco: "",
origem: "Instagram", valor: "", responsavelId: viewer.id, tags: [], observacoes: "", stageId: stages[0].id,
});
const [tagInput, setTagInput] = useState("");
const [err, setErr] = useState("");
const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
function addTag(v) { const t = v.trim(); if (t && !f.tags.includes(t)) set("tags", [...f.tags, t]); setTagInput(""); }
function save() {
if (!f.nome.trim()) return setErr("Informe o nome do contato.");
onSave({ ...f, valor: parseMoney(f.valor) });
}
return (
{editing && onDelete && Excluir }
Cancelar
{editing ? "Salvar" : "Cadastrar"}
>
}>
set("nome", e.target.value)} placeholder="Nome e sobrenome" />
set("empresa", e.target.value)} placeholder="Empresa" />
set("socios", e.target.value)} placeholder="Nomes dos sócios (separe por vírgula)" />
set("porte", e.target.value)}>
Selecione…
{["MEI", "Microempresa", "Pequena", "Média", "Grande"].map((p) => {p} )}
Contato
set("telefone", e.target.value)} placeholder="(54) 9 9999-9999" />
set("telefone2", e.target.value)} placeholder="(54) 9 9999-9999" />
set("telefone3", e.target.value)} placeholder="(54) 9 9999-9999" />
set("email", e.target.value)} placeholder="contato@empresa.com.br" />
set("endereco", e.target.value)} placeholder="Rua, número — Cidade/UF" />
Presença digital
set("instagram", e.target.value)} placeholder="@perfil" />
set("linkedin", e.target.value)} placeholder="linkedin.com/company/…" />
set("google", e.target.value)} placeholder="g.page/… ou site da empresa" />
Negócio
set("origem", e.target.value)}>
{ORIGENS.map((o) => {o} )}
set("valor", e.target.value)} placeholder="0" />
set("stageId", e.target.value)}>
{stages.map((s) => {s.nome} )}
set("responsavelId", e.target.value)}>
{users.map((u) => {u.nome}{u.id === viewer.id ? " (você)" : ""} )}
{f.tags.map((t) => (
{t} set("tags", f.tags.filter((x) => x !== t))}>
))}
setTagInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === ",") { e.preventDefault(); addTag(tagInput); } }} />
{editing && (
Agende uma reunião com este lead e conte em "Reuniões Agendadas".
onAgendar(f)}>Agendar reunião
)}
);
}
/* ---------------- Modal de importação CSV ---------------- */
const HEADER_MAP = {
nome: ["nome", "contato", "nome do contato", "lead", "name"],
empresa: ["empresa", "company", "cliente"],
telefone: ["telefone", "telefone1", "telefone 1", "whatsapp", "telefone / whatsapp", "fone", "celular", "phone"],
telefone2: ["telefone2", "telefone 2", "telefone secundario", "fone2"],
telefone3: ["telefone3", "telefone 3", "fone3"],
email: ["email", "e-mail", "mail"],
socios: ["socios", "socio", "nomes dos socios", "partners"],
instagram: ["instagram", "insta", "ig"],
google: ["google", "google business", "site", "website", "g.page"],
linkedin: ["linkedin", "linked in"],
porte: ["porte", "porte da empresa", "tamanho", "size"],
endereco: ["endereco", "endereço", "address", "localizacao"],
origem: ["origem", "fonte", "source", "canal"],
valor: ["valor", "valor potencial", "valor potencial (r$)", "valor (r$)", "valor potencial r$", "value"],
responsavel: ["responsavel", "responsavel email", "email responsavel", "responsavel (email)", "owner", "dono"],
tags: ["tags", "etiquetas", "labels"],
observacoes: ["observacoes", "observacoes / historico", "notas", "obs", "historico", "notes"],
etapa: ["etapa", "stage", "funil", "status", "fase"],
};
function normKey(h) { return String(h).toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim(); }
function buildColIndex(headers) {
const idx = {};
headers.forEach((h) => {
const n = normKey(h);
for (const field in HEADER_MAP) if (HEADER_MAP[field].includes(n)) idx[field] = h;
});
return idx;
}
function ImportModal({ stages, users, viewer, onClose, onImport }) {
const K = window.KES;
const isAdmin = viewer.papel === "admin";
const fileRef = useRef(null);
const [parsed, setParsed] = useState(null); // {headers, rows, idx, leads}
const [assignTo, setAssignTo] = useState("csv"); // 'csv' = conforme planilha | userId
const [erro, setErro] = useState("");
const applyAssign = (leads) => assignTo === "csv" ? leads : leads.map((l) => ({ ...l, responsavelId: assignTo }));
function handleText(text) {
try {
const { headers, rows } = window.CSVUtil.parse(text);
if (!rows.length) { setErro("Nenhuma linha encontrada no arquivo."); return; }
const idx = buildColIndex(headers);
if (!idx.nome) { setErro("Coluna obrigatória 'nome' não encontrada no cabeçalho."); return; }
const stageByName = {}; stages.forEach((s) => { stageByName[normKey(s.nome)] = s.id; });
const userByEmail = {}; users.forEach((u) => { userByEmail[u.email.toLowerCase()] = u.id; });
const leads = rows.map((r, i) => {
const g = (field) => (idx[field] ? r[idx[field]] : "");
const respEmail = (g("responsavel") || "").toLowerCase();
const stg = stageByName[normKey(g("etapa") || "")] || stages[0].id;
const tags = (g("tags") || "").split(/[;,/]/).map((t) => t.trim()).filter(Boolean);
return {
id: "l" + Date.now() + "_" + i,
nome: g("nome"), empresa: g("empresa"),
telefone: g("telefone"), telefone2: g("telefone2"), telefone3: g("telefone3"),
email: g("email"), socios: g("socios"),
instagram: g("instagram"), google: g("google"), linkedin: g("linkedin"),
porte: g("porte"), endereco: g("endereco"),
origem: g("origem") || "Outro", valor: parseMoney(g("valor")),
responsavelId: userByEmail[respEmail] || viewer.id,
tags, observacoes: g("observacoes"), stageId: stg, createdAt: K.iso(K.SIM_TODAY),
};
}).filter((l) => l.nome);
setErro(""); setParsed({ headers, count: leads.length, leads, idx });
} catch (e) { setErro("Não foi possível ler o arquivo. Verifique o formato CSV."); }
}
function onFile(e) {
const file = e.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = () => handleText(reader.result);
reader.readAsText(file, "UTF-8");
}
function baixarModelo() {
const rows = csvExampleRows(viewer, stages).map((r) => CSV_COLS.map((c) => r[c]));
window.CSVUtil.download("modelo-leads-kes.csv", window.CSVUtil.serialize(CSV_COLS, rows, ";"));
}
return (
Cancelar
onImport(applyAssign(parsed.leads))}>
{parsed ? `Importar ${parsed.count} leads` : "Importar"}
>
}>
{/* Modelo visual da planilha */}
Modelo da planilha
Baixar modelo (.csv)
{CSV_COLS.map((h) => {h} )}
{csvExampleRows(viewer, stages).map((r, i) => (
{CSV_COLS.map((c) => {r[c] || "—"} )}
))}
A 1ª linha deve ser o cabeçalho com esses nomes de coluna. Só nome é obrigatório. Separe tags e sócios por ponto-e-vírgula (;). Aceita separador "," ou ";".
fileRef.current.click()}>
Selecione um arquivo .CSV
Exportado do Excel ou Google Sheets
{/* Atribuição individual (admin) */}
{isAdmin && (
setAssignTo(e.target.value)}>
Conforme a coluna "responsavel" da planilha
{users.map((u) => {u.nome}{u.id === viewer.id ? " (você)" : ""} — todos para esta pessoa )}
)}
{erro && {erro}
}
{parsed && (
{parsed.count} leads prontos para importar
Colunas reconhecidas: {Object.keys(parsed.idx).join(", ") || "—"}
{isAdmin && assignTo !== "csv" && <> · todos serão atribuídos a {(users.find((u) => u.id === assignTo) || {}).nome} >}
)}
);
}
/* ---------------- Modal: configurar etapas ---------------- */
function StagesModal({ stages, onClose, onSave }) {
const [list, setList] = useState(() => stages.map((s) => ({ ...s })));
const [novo, setNovo] = useState("");
function rename(i, v) { setList((l) => l.map((s, j) => j === i ? { ...s, nome: v } : s)); }
function move(i, dir) {
const j = i + dir; if (j < 0 || j >= list.length) return;
const l = list.slice(); const t = l[i]; l[i] = l[j]; l[j] = t; setList(l);
}
function remove(i) { if (list.length <= 2) return; setList((l) => l.filter((_, j) => j !== i)); }
function add() {
const n = novo.trim(); if (!n) return;
setList((l) => [...l, { id: "s" + Date.now(), nome: n, tipo: "open" }]); setNovo("");
}
return (
Cancelar onSave(list)}>Salvar etapas >}>
{list.map((s, i) => (
))}
setNovo(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }} />
Adicionar
Ao remover uma etapa, os leads dela vão para a primeira etapa.
);
}
/* ---------------- Tela CRM ---------------- */
function CRMScreen({ state, dispatch, viewer }) {
const K = window.KES;
const isAdmin = viewer.papel === "admin";
const usersById = {}; state.users.forEach((u) => { usersById[u.id] = u; });
const [modal, setModal] = useState(null); // lead em edição/criação
const [importing, setImporting] = useState(false);
const [stagesCfg, setStagesCfg] = useState(false);
const [q, setQ] = useState("");
const [fResp, setFResp] = useState("all");
const [fOrig, setFOrig] = useState("all");
const dragId = useRef(null);
const [, force] = useState(0);
// leads visíveis conforme permissão + filtros
let leads = state.leads.filter((l) => isAdmin || l.responsavelId === viewer.id);
if (isAdmin && fResp !== "all") leads = leads.filter((l) => l.responsavelId === fResp);
if (fOrig !== "all") leads = leads.filter((l) => l.origem === fOrig);
if (q.trim()) {
const s = q.toLowerCase();
leads = leads.filter((l) => (l.nome + " " + l.empresa + " " + l.email).toLowerCase().includes(s));
}
const totalValor = leads.reduce((t, l) => t + (l.valor || 0), 0);
const drag = {
id: dragId.current,
start: (e, lead) => { dragId.current = lead.id; e.dataTransfer.effectAllowed = "move"; force((n) => n + 1); },
end: () => { dragId.current = null; force((n) => n + 1); },
};
function dropOn(stageId) {
if (dragId.current) { dispatch({ type: "MOVE_LEAD", id: dragId.current, stageId }); dragId.current = null; force((n) => n + 1); }
}
function agendar(lead) { dispatch({ type: "AGENDAR_LEAD", lead }); }
function saveLead(data) {
if (modal && modal.id) dispatch({ type: "UPDATE_LEAD", id: modal.id, patch: data });
else dispatch({ type: "ADD_LEAD", lead: data });
dispatch({ type: "TOAST", msg: modal && modal.id ? "Lead atualizado." : "Lead cadastrado." });
setModal(null);
}
function exportar() {
const stageById = {}; state.stages.forEach((s) => { stageById[s.id] = s.nome; });
const val = (l, c) => {
if (c === "responsavel") return (usersById[l.responsavelId] || {}).email || "";
if (c === "tags") return (l.tags || []).join(";");
if (c === "etapa") return stageById[l.stageId] || "";
return l[c] != null ? l[c] : "";
};
const rows = leads.map((l) => CSV_COLS.map((c) => val(l, c)));
window.CSVUtil.download("leads-kes.csv", window.CSVUtil.serialize(CSV_COLS, rows, ";"));
dispatch({ type: "TOAST", msg: `${rows.length} leads exportados (CSV).` });
}
return (
CRM — Funil de Vendas
{leads.length} leads · {K.fmtMoeda(totalValor)} em oportunidades{isAdmin ? "" : " · seus leads"}
setImporting(true)}>Importar
Exportar
{isAdmin && setStagesCfg(true)}>Etapas }
setModal({})}>Novo lead
setQ(e.target.value)} />
{isAdmin && (
setFResp(e.target.value)}>
Todos os responsáveis
{state.users.map((u) => {u.nome} )}
)}
setFOrig(e.target.value)}>
Todas as origens
{ORIGENS.map((o) => {o} )}
{state.stages.map((stage) => (
l.stageId === stage.id)}
usersById={usersById} drag={drag}
onDropLead={dropOn} onOpen={(l) => setModal(l)} onAgendar={agendar} />
))}
{modal && (
setModal(null)} onSave={saveLead} onAgendar={(l) => { setModal(null); agendar(l); }}
onDelete={modal.id ? () => { dispatch({ type: "DELETE_LEAD", id: modal.id }); dispatch({ type: "TOAST", msg: "Lead excluído." }); setModal(null); } : null} />
)}
{importing && (
setImporting(false)}
onImport={(leads) => { dispatch({ type: "IMPORT_LEADS", leads }); dispatch({ type: "TOAST", msg: `${leads.length} leads importados.` }); setImporting(false); }} />
)}
{stagesCfg && (
setStagesCfg(false)}
onSave={(list) => { dispatch({ type: "SET_STAGES", stages: list }); dispatch({ type: "TOAST", msg: "Etapas atualizadas." }); setStagesCfg(false); }} />
)}
);
}
window.CRMScreen = CRMScreen;