@danhachuel/leadmaster 0.1.24 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/backend/__pycache__/main.cpython-311.pyc +0 -0
  3. package/backend/main.py +68 -1
  4. package/backend/services/__pycache__/__init__.cpython-311.pyc +0 -0
  5. package/backend/services/__pycache__/agents.cpython-311.pyc +0 -0
  6. package/backend/services/__pycache__/crm_agent.cpython-311.pyc +0 -0
  7. package/backend/services/__pycache__/extractors.cpython-311.pyc +0 -0
  8. package/backend/services/__pycache__/flags.cpython-311.pyc +0 -0
  9. package/backend/services/__pycache__/llm.cpython-311.pyc +0 -0
  10. package/backend/services/__pycache__/storage.cpython-311.pyc +0 -0
  11. package/backend/tests/__pycache__/conftest.cpython-311-pytest-8.4.2.pyc +0 -0
  12. package/backend/tests/__pycache__/test_agents_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  13. package/backend/tests/__pycache__/test_campaigns_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  14. package/backend/tests/__pycache__/test_cnpj_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  15. package/backend/tests/__pycache__/test_config.cpython-311-pytest-8.4.2.pyc +0 -0
  16. package/backend/tests/__pycache__/test_crm_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  17. package/backend/tests/__pycache__/test_extractors_real.cpython-311-pytest-8.4.2.pyc +0 -0
  18. package/backend/tests/__pycache__/test_funnel_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  19. package/backend/tests/__pycache__/test_instagram_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  20. package/backend/tests/__pycache__/test_launcher_protections.cpython-311-pytest-8.4.2.pyc +0 -0
  21. package/backend/tests/__pycache__/test_lead_edit_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  22. package/backend/tests/__pycache__/test_leads_export.cpython-311-pytest-8.4.2.pyc +0 -0
  23. package/backend/tests/__pycache__/test_maps_extractor.cpython-311-pytest-8.4.2.pyc +0 -0
  24. package/backend/tests/__pycache__/test_metrics.cpython-311-pytest-8.4.2.pyc +0 -0
  25. package/backend/tests/__pycache__/test_process_flags.cpython-311-pytest-8.4.2.pyc +0 -0
  26. package/backend/tests/__pycache__/test_webhook_routes.cpython-311-pytest-8.4.2.pyc +0 -0
  27. package/backend/tests/test_lead_edit_routes.py +63 -0
  28. package/backend/tests/test_leads_export.py +56 -0
  29. package/frontend/dist/assets/index-D5fh14uA.js +90 -0
  30. package/frontend/dist/assets/{index-ViDiQGJH.css → index-NeGNWLr8.css} +1 -1
  31. package/frontend/dist/index.html +2 -2
  32. package/package.json +1 -1
  33. package/scripts/cli.mjs +13 -0
  34. package/frontend/dist/assets/index-B_SzrvJP.js +0 -90
package/CHANGELOG.md CHANGED
@@ -37,6 +37,52 @@ normaliza para a forma canônica (ex.: `0.1.0`).
37
37
  falha se `process.stdin.resume();` desaparecer, se `.unref()` aparecer, se o
38
38
  ciclo de vida vinculado for removido ou se os handlers globais sumirem.
39
39
 
40
+ ## [0.1.26] — 2026-11-09
41
+
42
+ ### Adicionado
43
+
44
+ - **Busca no CRM e exportação CSV:**
45
+ - `GET /api/crm/leads/export`: gera CSV de todos os leads com **BOM UTF-8**
46
+ (`\ufeff`, para o Excel em português não quebrar acentos), delimiter `;`,
47
+ `Content-Type: text/csv; charset=utf-8` e
48
+ `Content-Disposition: attachment; filename="leads_leadmaster.csv"`. Campos
49
+ de `extras` são **achatados** como colunas `extras_<chave>` (união das
50
+ chaves existentes) e listas (tags) viram células separadas por `; `.
51
+ - Frontend: **barra de busca larga** no topo do CRM com filtragem local
52
+ instantânea por **Nome, Telefone ou Email** + botão de limpar; **botão
53
+ "Exportar CSV"** em estilo **neutro** (outline — não compete com o
54
+ "Adicionar Lead" em gradiente, von Restorff) que baixa o arquivo via blob;
55
+ busca sem resultados mostra aviso amigável *"Nenhum lead encontrado para
56
+ '...'"* (Zeigarnik).
57
+ - **Corrigido (pego pelos novos testes):** o modelo `LeadIn` descartava o campo
58
+ `extras` — leads criados manualmente perdiam site/avaliação. Agora o POST de
59
+ leads preserva `extras`.
60
+ - **3 novos testes pytest** (`backend/tests/test_leads_export.py`): MIME +
61
+ disposition + BOM + colunas, achatamento de `extras` e exportação de base
62
+ vazia (só cabeçalho).
63
+
64
+ ## [0.1.25] — 2026-11-09
65
+
66
+ ### Adicionado
67
+
68
+ - **Modal de Detalhes do Lead no CRM (clique no card):**
69
+ - Rota `PUT /api/crm/leads/{lead_id}` — edição de `nome`, `telefone` e `email`
70
+ (todos opcionais no payload); nome vazio → **400** (prevenção de erros),
71
+ lead inexistente → **404**, payload sem campos editáveis → **400**.
72
+ - Card do Kanban passa a abrir os detalhes no **clique simples** (antes era
73
+ duplo clique); flag global suprime o clique disparado no pointerup após um
74
+ drag, então abrir detalhes e arrastar convivem sem conflito.
75
+ - Modal "Detalhes do Lead" exibe **todos os dados** — inclusive `extras`
76
+ (Site, Endereço, Avaliação, Seguidores, Username, Bio), tags, fonte e data
77
+ de criação — com **inputs de edição** para Nome/Telefone/E-mail pré-preenchidos
78
+ (vermelho + mensagem se o nome for apagado), textarea de observações e botão
79
+ **"Salvar Alterações"** grande em gradiente (Fitts + von Restorff), com
80
+ **spinner** durante o salvamento (Zeigarnik), toast de sucesso e
81
+ **atualização automática** do Kanban. O "Fechar" (X) permanece sutil.
82
+ - **5 novos testes pytest** (`backend/tests/test_lead_edit_routes.py`): edição
83
+ completa, edição parcial (campos omitidos preservados), 404, nome vazio (400)
84
+ e payload sem campos editáveis (400).
85
+
40
86
  ## [0.1.24] — 2026-11-09
41
87
 
42
88
  ### Corrigido
package/backend/main.py CHANGED
@@ -13,7 +13,7 @@ import threading
13
13
  from contextlib import asynccontextmanager
14
14
  from pathlib import Path
15
15
 
16
- from fastapi import FastAPI, HTTPException
16
+ from fastapi import FastAPI, HTTPException, Response
17
17
  from fastapi.middleware.cors import CORSMiddleware
18
18
  from fastapi.responses import FileResponse
19
19
  from fastapi.staticfiles import StaticFiles
@@ -94,6 +94,8 @@ class LeadIn(BaseModel):
94
94
  observacoes: str = ""
95
95
  score: int | None = None
96
96
  tags: list[str] = []
97
+ extras: dict = {}
98
+ extras: dict | None = None
97
99
 
98
100
 
99
101
  class LeadsImportIn(BaseModel):
@@ -344,6 +346,71 @@ def crm_mover_lead(lead_id: str, dados: dict):
344
346
  return atualizado
345
347
 
346
348
 
349
+ @app.get("/api/crm/leads/export")
350
+ def crm_exportar_leads_csv():
351
+ """Exporta todos os leads em CSV (UTF-8 com BOM, amigável ao Excel)."""
352
+ import csv
353
+ import io
354
+
355
+ leads = storage.list_leads()
356
+
357
+ def _valor_celula(valor) -> str:
358
+ if valor is None:
359
+ return ""
360
+ if isinstance(valor, (list, tuple)):
361
+ return "; ".join(str(v) for v in valor)
362
+ return str(valor)
363
+
364
+ # Colunas fixas + união das chaves de `extras` (achatadas com prefixo).
365
+ colunas = [
366
+ "id", "nome", "telefone", "email", "fonte", "etapa", "empresa",
367
+ "cnpj", "endereco", "cidade", "observacoes", "score", "tags",
368
+ "criado_em", "atualizado_em",
369
+ ]
370
+ extras_keys: list[str] = []
371
+ for lead in leads:
372
+ for chave in (lead.get("extras") or {}):
373
+ coluna = f"extras_{chave}"
374
+ if coluna not in extras_keys:
375
+ extras_keys.append(coluna)
376
+ colunas.extend(sorted(extras_keys))
377
+
378
+ buffer = io.StringIO()
379
+ escritor = csv.writer(buffer, delimiter=";", quoting=csv.QUOTE_MINIMAL)
380
+ escritor.writerow(colunas)
381
+ for lead in leads:
382
+ linha = [_valor_celula(lead.get(coluna)) for coluna in colunas[: len(colunas) - len(extras_keys)]]
383
+ extras = lead.get("extras") or {}
384
+ for coluna in sorted(extras_keys):
385
+ linha.append(_valor_celula(extras.get(coluna[len("extras_"):])))
386
+ escritor.writerow(linha)
387
+
388
+ conteudo = "\ufeff" + buffer.getvalue() # BOM: Excel em pt-BR lê os acentos
389
+ return Response(
390
+ content=conteudo.encode("utf-8"),
391
+ media_type="text/csv; charset=utf-8",
392
+ headers={"Content-Disposition": 'attachment; filename="leads_leadmaster.csv"'},
393
+ )
394
+
395
+
396
+ @app.put("/api/crm/leads/{lead_id}")
397
+ def crm_editar_lead(lead_id: str, dados: dict):
398
+ """Edita os dados principais do lead (modal de detalhes do CRM).
399
+
400
+ Payload aceita qualquer subconjunto de {nome, telefone, email, ...}; o nome,
401
+ quando presente, não pode ficar vazio (prevenção de erros).
402
+ """
403
+ if "nome" in dados and not str(dados.get("nome") or "").strip():
404
+ raise HTTPException(400, "O nome do lead não pode ficar vazio.")
405
+ patch = {k: v for k, v in dados.items() if k in ("nome", "telefone", "email")}
406
+ if not patch:
407
+ raise HTTPException(400, "Nenhum campo editável informado (nome, telefone, email).")
408
+ atualizado = storage.update_lead(lead_id, patch)
409
+ if not atualizado:
410
+ raise HTTPException(404, "Lead não encontrado.")
411
+ return atualizado
412
+
413
+
347
414
  # ---------------------------------------------------------------------------
348
415
  # Campanhas / Disparos
349
416
  # ---------------------------------------------------------------------------
@@ -0,0 +1,63 @@
1
+ """Testes da edição do lead — PUT /api/crm/leads/{lead_id}.
2
+
3
+ Edição de nome/telefone/email (todos opcionais), persistência, 404 para ID
4
+ inexistente e bloqueio de nome vazio (prevenção de erros).
5
+ """
6
+
7
+ import pytest
8
+ from fastapi.testclient import TestClient
9
+
10
+ import main
11
+
12
+
13
+ @pytest.fixture()
14
+ def client(tmp_path, monkeypatch):
15
+ monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
16
+ with TestClient(main.app) as c:
17
+ yield c
18
+
19
+
20
+ def _criar_lead(client):
21
+ return client.post("/api/crm/leads", json={"nome": "Lead Original", "telefone": "(11) 90000-0000", "email": "a@b.com"}).json()
22
+
23
+
24
+ def test_editar_campos_principais(client):
25
+ lead = _criar_lead(client)
26
+ r = client.put(
27
+ f"/api/crm/leads/{lead['id']}",
28
+ json={"nome": "Lead Editado", "telefone": "(21) 98888-7777", "email": "novo@empresa.com"},
29
+ )
30
+ assert r.status_code == 200
31
+ corpo = r.json()
32
+ assert corpo["nome"] == "Lead Editado"
33
+ assert corpo["telefone"] == "(21) 98888-7777"
34
+ assert corpo["email"] == "novo@empresa.com"
35
+ # Persistido no leads.json (a listagem reflete a edição).
36
+ salvo = [l for l in client.get("/api/crm/leads").json() if l["id"] == lead["id"]][0]
37
+ assert salvo["nome"] == "Lead Editado"
38
+
39
+
40
+ def test_editar_parcial(client):
41
+ lead = _criar_lead(client)
42
+ r = client.put(f"/api/crm/leads/{lead['id']}", json={"email": "parcial@x.com"})
43
+ assert r.status_code == 200
44
+ assert r.json()["email"] == "parcial@x.com"
45
+ assert r.json()["nome"] == "Lead Original" # campos omitidos permanecem
46
+
47
+
48
+ def test_editar_inexistente_404(client):
49
+ r = client.put("/api/crm/leads/nao-existe", json={"nome": "X"})
50
+ assert r.status_code == 404
51
+
52
+
53
+ def test_nome_vazio_bloqueado(client):
54
+ lead = _criar_lead(client)
55
+ r = client.put(f"/api/crm/leads/{lead['id']}", json={"nome": " "})
56
+ assert r.status_code == 400
57
+ assert "vazio" in r.json()["detail"]
58
+
59
+
60
+ def test_payload_sem_campos_editaveis(client):
61
+ lead = _criar_lead(client)
62
+ r = client.put(f"/api/crm/leads/{lead['id']}", json={"etapa": "fechado"})
63
+ assert r.status_code == 400
@@ -0,0 +1,56 @@
1
+ """Testes da exportação CSV de leads — GET /api/crm/leads/export.
2
+
3
+ Valida MIME, Content-Disposition, BOM (acentos no Excel), colunas esperadas e
4
+ presença dos dados dos leads no conteúdo.
5
+ """
6
+
7
+ import pytest
8
+ from fastapi.testclient import TestClient
9
+
10
+ import main
11
+
12
+
13
+ @pytest.fixture()
14
+ def client(tmp_path, monkeypatch):
15
+ monkeypatch.setenv("LEADMASTER_HOME", str(tmp_path))
16
+ with TestClient(main.app) as c:
17
+ yield c
18
+
19
+
20
+ def test_exportar_csv(client):
21
+ r = client.get("/api/crm/leads/export")
22
+ assert r.status_code == 200
23
+ assert r.headers["content-type"].startswith("text/csv")
24
+ assert 'attachment; filename="leads_leadmaster.csv"' in r.headers["content-disposition"]
25
+
26
+ conteudo = r.content.decode("utf-8-sig") # remove o BOM para inspecionar
27
+ assert r.content.startswith(b"\xef\xbb\xbf") # BOM presente (Excel pt-BR)
28
+ linhas = [l for l in conteudo.splitlines() if l.strip()]
29
+ cabecalho = linhas[0].split(";")
30
+ for coluna in ("id", "nome", "telefone", "email", "fonte", "etapa"):
31
+ assert coluna in cabecalho
32
+ # Um lead de demonstração (seed) aparece no conteúdo.
33
+ assert any("Bella Massa" in l or "Horizonte" in l for l in linhas[1:])
34
+
35
+
36
+ def test_exportar_csv_achata_extras(client):
37
+ # Cria um lead com extras (dados do scraper real).
38
+ client.post(
39
+ "/api/crm/leads",
40
+ json={"nome": "Empresa com Extras", "telefone": "(11) 95555-0000", "extras": {"site": "https://empresa.com", "avaliacao": 4.7}},
41
+ )
42
+ r = client.get("/api/crm/leads/export")
43
+ assert r.status_code == 200
44
+ conteudo = r.content.decode("utf-8-sig")
45
+ assert "extras_site" in conteudo.splitlines()[0]
46
+ assert "https://empresa.com" in conteudo
47
+
48
+
49
+ def test_exportar_csv_sem_leads(client):
50
+ # Base vazia: só o cabeçalho.
51
+ home = main.storage.storage_dir()
52
+ (home / "leads.json").write_text("[]", encoding="utf-8")
53
+ r = client.get("/api/crm/leads/export")
54
+ assert r.status_code == 200
55
+ linhas = [l for l in r.content.decode("utf-8-sig").splitlines() if l.strip()]
56
+ assert len(linhas) == 1 # apenas o cabeçalho