Runbook — Kanban do Sagazchat

Este runbook descreve como uma IA navega, audita e opera o módulo Kanban do Sagazchat via Playwright. Acompanha o doc user-facing em src/content/atendimento/kanban.md. Foi escrito

8 min de leitura Atualizado em 15 de mai. de 2026

Este runbook descreve como uma IA navega, audita e opera o módulo Kanban do Sagazchat via Playwright. Acompanha o doc user-facing em src/content/atendimento/kanban.md. Foi escrito após auditoria completa em 2026-05-09 (sessão TESTE-IA), com cleanup ao final.

Status: todos os snippets marcados [validado] foram executados nesta sessão. Os marcados [a validar] foram inferidos da UI mas dependem de cenário específico (ex.: automação por etiqueta no Bate Papo, que cruza com o runbook bate-papo).


1. Pré-requisitos

Idênticos ao runbook do Bate Papo (bate-papo § 1). Em resumo: Playwright local 1.59.1, Chromium do Playwright já instalado, sessão persistente CDP em scripts/_out/cdp.txt, viewport 1440×900.

2. Navegação

  • URL da lista: https://app.sagazchat.com/kanban-list.
  • URL de um Kanban: https://app.sagazchat.com/kanban/<uuid>.
  • Sidebar: Criação › Kanban's — texto exato Kanban's com apóstrofo. Use page.locator('text=/^Kanban/i').first() para evitar confusão com itens “Kanban” em outros menus.

3. Mapa da UI

3.1 Tela /kanban-list

ElementoCoordenadas (1440×900)Seletor
Título “Kanban’s”(335, 30)
Busca “Pesquisar…”(1080, 30)input[placeholder="Pesquisar..."]
Botão + Adicionar(1290, 20)getByRole('button', { name: 'Adicionar' })
Linha de Kanbany ~150–250 (varia)text=<NomeDoKanban>
Kebab da linhax ≈ 1380, y = mesma linhabotão MUI sem aria; identificar pela posição

Menu do kebab: Editar nome, Excluir.

3.2 Tela do Kanban (/kanban/<uuid>)

ElementoPosição típicaSeletor
Seta voltar <(320, 30)botão sem aria, ao lado do título
Nome do Kanban(335, 30)h1/h2 com texto exato
Criar coluna(1129, 16)getByRole('button', { name: /^Criar coluna$/ }) (use regex — mesmo texto aparece dentro do empty state)
Criar cartão(1280, 16)getByRole('button', { name: /^Criar cartão$/ }) (DISABLED enquanto não houver coluna)
Busca interna(320, 100)input[placeholder="Procure por nome"]
Refresh (1395, 100)botão pequeno, sem aria
Coluna (header)y ≈ 180–230ver § 3.3

3.3 Header da coluna

Cada coluna tem na linha de cabeçalho (y ≈ 200):

  • Barra colorida vertical à esquerda (cor random).
  • Nome (negrito).
  • Soma de valores “R$ 0,00” abaixo do nome.
  • Contador numérico (quantidade de cartões).
  • Ícone de menu (parece etiqueta 🏷️ mas é botão de ações). Clicar abre menu Editar / Excluir.

A posição do ícone depende da quantidade de colunas e largura. Para um Kanban com 3 colunas em 1440px, o ícone fica aproximadamente em x: 595 (col 1), 930 (col 2), 1265 (col 3), todos em y ≈ 211.

Helper recomendado — não confie nas coordenadas hardcoded:

async function colIconPos(colName) {
  return await page.evaluate((target) => {
    const els = Array.from(document.querySelectorAll('p, span'));
    const hdr = els.find(el => (el.innerText || '').trim() === target);
    if (!hdr) return null;
    let cur = hdr;
    for (let i = 0; i < 8; i++) {
      cur = cur.parentElement;
      if (!cur) return null;
      const btn = cur.querySelector('button.MuiIconButton-root');
      if (btn) {
        const r = btn.getBoundingClientRect();
        return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
      }
    }
    return null;
  }, colName);
}

3.4 Cartão

Cada cartão ocupa ~286px × 134px, com:

  • Avatar circular + indicador online (ponto verde) no canto superior esquerdo.
  • Nome do contato + valor “R$ X,XX” à direita.
  • Linha inferior: ícone WhatsApp + “0/19” + ícone relógio + “7d” (números de mensagens lidas/total e tempo desde última atividade).

Ações:

  • Doubleclick abre o modal Editar card.
  • Click no ícone WhatsApp (canto inferior esquerdo do card, ~22px × 22px) navega direto para /live-chat-whatsapp/<uuid-conversa> com a conversa do contato aberta. Atalho mais rápido que doubleclick → modal → botão Chat.
  • Click simples no resto do card NÃO faz nada. Hover NÃO mostra kebab. Right-click NÃO abre context menu (apenas o nativo do navegador).

4. Ações disparadoras vs seguras

AçãoDisparadora?Observação
Abrir modal Criar KanbanNãoCancelar fecha sem efeito
Criar KanbanSimCria registro real; precisa cleanup depois
Editar nome do KanbanSimRenomeia o registro
Excluir KanbanSim — destrutivaApaga Kanban + todas colunas/cartões. Sem undo
Abrir modal Criar colunaNãoCancelar fecha
Criar colunaSimCria registro real
Editar coluna (cancelar)Não
Editar coluna (salvar)SimRenomeia / vincula etiqueta
Excluir colunaSim — destrutivaApaga cartões da coluna; contatos permanecem
Abrir modal Novo cardNão
Criar card (Tipo: Contato)Sim1 cartão
Criar card (Tipo: Por etiqueta)Sim — em massaCria N cartões (1 por contato com a etiqueta) — atenção em etiquetas com muitos contatos
Doubleclick em cardNãoApenas abre modal
Editar card (cancelar)Não
Editar card (salvar)SimAtualiza valor / move coluna
Excluir cardSim — destrutivaRemove o cartão; contato permanece
Drag-and-dropSimMove o cartão para outra coluna
Click em Chat do modal Editar cardSim — navegaSai do Kanban e vai para /live-chat-whatsapp com a conversa do contato aberta

5. Snippets prontos

Todos assumem connect() do scripts/_lib/connect.mjs.

5.1 Listar Kanbans existentes [validado]

await page.goto('https://app.sagazchat.com/kanban-list');
await page.waitForTimeout(2000);
const kanbans = await page.evaluate(() => {
  const rows = Array.from(document.querySelectorAll('p, span, h6'));
  return rows
    .filter(r => {
      const box = r.getBoundingClientRect();
      return box.x > 320 && box.x < 700 && box.y > 100 && box.y < 800;
    })
    .map(r => (r.innerText || '').trim())
    .filter(t => t && t.length < 60 && t.length > 0);
});

5.2 Criar Kanban [validado]

await page.getByRole('button', { name: 'Adicionar' }).click();
await page.waitForTimeout(1200);
await page.locator('input[name="name"]').fill('TESTE-IA');
await page.getByRole('button', { name: 'Adicionar' }).last().click();
await page.waitForTimeout(2500);

5.3 Abrir Kanban pelo nome [validado]

await page.locator('text=TESTE-IA').first().click();
await page.waitForTimeout(2500);
// page.url() agora é /kanban/<uuid>

5.4 Renomear Kanban (kebab da linha) [a validar]

// kebab está em x ≈ 1380, y = linha do Kanban — descobrir a linha primeiro
const rowY = await page.evaluate((name) => {
  const el = Array.from(document.querySelectorAll('p, span'))
    .find(e => (e.innerText || '').trim() === name);
  return el ? Math.round(el.getBoundingClientRect().y + 12) : null;
}, 'TESTE-IA');
await page.mouse.click(1380, rowY);
await page.waitForTimeout(1000);
await page.locator('text=Editar nome').click();
await page.waitForTimeout(1500);
// preencher novo nome e salvar

5.5 Excluir Kanban [validado em fluxo de cleanup]

const rowY = /* mesmo do 5.4 */;
await page.mouse.click(1380, rowY);
await page.waitForTimeout(1000);
await page.locator('text=Excluir').click();
await page.waitForTimeout(1500);
// modal de confirmação: "Deletar <nome>? Tem certeza que deseja deletar este fluxo?
// Todas as integrações relacionados serão perdidos." (texto reaproveitado de Fluxos)
// Botões: Cancelar / Ok
await page.getByRole('button', { name: /^Ok$/ }).click();
await page.waitForTimeout(3000);

5.6 Criar coluna (sem etiqueta) [validado]

await page.getByRole('button', { name: /^Criar coluna$/ }).first().click();
await page.waitForTimeout(1200);
await page.locator('input[placeholder="Nome da coluna"]').fill('Lead novo');
await page.getByRole('button', { name: 'Criar coluna' }).last().click();
await page.waitForTimeout(2500);

5.7 Criar coluna com etiqueta vinculada [validado]

await page.getByRole('button', { name: /^Criar coluna$/ }).first().click();
await page.waitForTimeout(1200);
await page.locator('input[placeholder="Nome da coluna"]').fill('Negociando');
await page.locator('input[placeholder="Vincular etiqueta (opcional)"]').click();
await page.waitForTimeout(500);
await page.locator('input[placeholder="Vincular etiqueta (opcional)"]').fill('Cliente quente');
await page.waitForTimeout(800);
await page.locator('[role="option"]').filter({ hasText: 'Cliente quente' }).first().click();
await page.getByRole('button', { name: 'Criar coluna' }).last().click();
await page.waitForTimeout(2500);

5.8 Editar coluna (renomear ou trocar etiqueta) [validado]

const icon = await colIconPos('Negociando'); // helper § 3.3
await page.mouse.click(icon.x, icon.y);
await page.waitForTimeout(800);
await page.locator('text=Editar').first().click();
await page.waitForTimeout(1500);
// alterar campos…
await page.getByRole('button', { name: 'Salvar' }).click();
await page.waitForTimeout(2500);

5.9 Excluir coluna [validado em modal — cancelado para não destruir]

const icon = await colIconPos('NomeDaColuna');
await page.mouse.click(icon.x, icon.y);
await page.waitForTimeout(800);
await page.locator('text=Excluir').first().click();
await page.waitForTimeout(1500);
// confirmação: "Tem certeza de que deseja excluir esta coluna de forma definitiva?"
await page.getByRole('button', { name: /^Excluir$/ }).click(); // ATENÇÃO destrutivo

5.10 Criar cartão Tipo: Contato [validado]

await page.getByRole('button', { name: /^Criar cartão$/ }).click();
await page.waitForTimeout(1200);

// Tipo: Contato
await page.locator('div').filter({ hasText: /^Tipo$/ }).first().click();
await page.waitForTimeout(800);
await page.locator('[role="option"]').filter({ hasText: /^Contato$/ }).click();
await page.waitForTimeout(500);

// Contato
await page.locator('[placeholder="Selecione um contato"]').click();
await page.locator('[placeholder="Selecione um contato"]').fill('Exemplo');
await page.waitForTimeout(1500);
await page.locator('[role="option"]').filter({ hasText: 'Fulano Exemplo' }).first().click();
await page.waitForTimeout(500);

// Coluna
await page.locator('[placeholder="Selecione uma coluna"]').click();
await page.waitForTimeout(800);
await page.locator('[role="option"]').filter({ hasText: 'Lead novo' }).first().click();
await page.waitForTimeout(500);

await page.getByRole('button', { name: /Criar Card/i }).click();
await page.waitForTimeout(2500);

5.11 Criar cartão Tipo: Por etiqueta [a validar — cria em massa]

await page.getByRole('button', { name: /^Criar cartão$/ }).click();
await page.waitForTimeout(1200);
await page.locator('div').filter({ hasText: /^Tipo$/ }).first().click();
await page.locator('[role="option"]').filter({ hasText: 'Por etiqueta' }).click();
await page.waitForTimeout(800);
// Campo "Selecione uma etiqueta" substituiu "Selecione um contato"
await page.locator('[placeholder="Selecione uma etiqueta"]').click();
await page.waitForTimeout(500);
await page.locator('[role="option"]').filter({ hasText: 'Cliente Ativo' }).first().click();
// Coluna
await page.locator('[placeholder="Selecione uma coluna"]').click();
await page.locator('[role="option"]').filter({ hasText: 'Lead novo' }).first().click();
await page.getByRole('button', { name: /Criar Card/i }).click();
// CRIA N CARTÕES — 1 por contato com a etiqueta

5.12 Abrir modal Editar card [validado]

// Bounding box do cartão pelo nome do contato
const cardBox = await page.evaluate(() => {
  const els = Array.from(document.querySelectorAll('p, span, div'));
  const target = els.find(el => (el.innerText || '').trim() === 'Fulano Exemplo' && el.getBoundingClientRect().y > 250);
  if (!target) return null;
  let cur = target;
  for (let i = 0; i < 10; i++) {
    cur = cur.parentElement;
    if (!cur) break;
    const r = cur.getBoundingClientRect();
    if (r.height > 80 && r.width > 200) return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
  }
  return null;
});
await page.mouse.dblclick(cardBox.x, cardBox.y);
await page.waitForTimeout(2000);

5.13 Atualizar valor do card [validado]

Atenção: o input de valor é em centavos. Digitar 100 registra R$ 1,00; para R$ 100,00 digite 10000.

// modal Editar card aberto (5.12)
const inputPos = await page.evaluate(() => {
  const dialog = document.querySelector('.MuiDialog-paper');
  const valorLabel = Array.from(dialog.querySelectorAll('legend, label, p, span')).find(el => (el.innerText || '').trim() === 'Valor');
  let cur = valorLabel;
  for (let i = 0; i < 8; i++) {
    cur = cur.parentElement;
    const inp = cur.querySelector('input');
    if (inp && inp.type !== 'search') {
      const r = inp.getBoundingClientRect();
      return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
    }
  }
  return null;
});
await page.mouse.click(inputPos.x, inputPos.y);
await page.keyboard.type('10000', { delay: 50 }); // R$ 100,00
await page.getByRole('button', { name: 'Salvar' }).click();
await page.waitForTimeout(2500);

5.14 Mover cartão via dropdown do modal [validado]

// modal Editar card aberto
await page.locator('div').filter({ hasText: /^Lead novo$/ }).last().click(); // dropdown coluna
await page.waitForTimeout(800);
await page.locator('[role="option"]').filter({ hasText: /^Negociando$/ }).first().click();
await page.getByRole('button', { name: 'Salvar' }).click();
await page.waitForTimeout(2500);

5.15 Drag-and-drop entre colunas [validado]

// cardBox calculado em 5.12; targetX é o centro da coluna destino
await page.mouse.move(cardBox.x, cardBox.y, { steps: 5 });
await page.mouse.down();
await page.waitForTimeout(300);
await page.mouse.move(targetX, cardBox.y, { steps: 25 });
await page.waitForTimeout(300);
await page.mouse.move(targetX, cardBox.y + 30, { steps: 5 }); // entrar na lista
await page.mouse.up();
await page.waitForTimeout(2500);

5.16 Excluir cartão [a validar — destrutivo]

// modal Editar card aberto
await page.getByRole('button', { name: /^Excluir$/ }).click();
// pode haver confirmação adicional

5.17 Abrir conversa do contato direto pelo card [validado]

Atalho rápido — click no ícone WhatsApp do card:

// procurar o SVG do whatsapp dentro da bbox do card
const wppPos = await page.evaluate(() => {
  const svgs = Array.from(document.querySelectorAll('svg'));
  // ícone whatsapp fica na linha inferior do card (y ~370 em viewport 1440x900)
  const candidatos = svgs.filter(s => {
    const r = s.getBoundingClientRect();
    return r.x > 320 && r.x < 615 && r.y > 350 && r.y < 410 && r.width > 12 && r.width < 25;
  });
  if (candidatos.length === 0) return null;
  const r = candidatos[0].getBoundingClientRect(); // o whatsapp é o mais à esquerda
  return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
});
await page.mouse.click(wppPos.x, wppPos.y);
await page.waitForTimeout(3500);
// page.url() agora é /live-chat-whatsapp/<uuid-conversa>

Alternativa lenta — botão Chat do modal Editar card:

// (modal Editar card já aberto via doubleclick - § 5.12)
await page.getByRole('button', { name: 'Chat' }).click();
await page.waitForTimeout(3000);

6. Quirks aprendidas

  • Doubleclick é a única forma de abrir o detalhe do cartão. Click simples, hover, right-click — nenhum funciona.
  • Botões “Criar coluna” e “Adicionar” duplicados. O texto exato aparece no header E no modal. Use .first() ou .last() ou regex ^...$ consistentemente. No modal de criação, sempre use .last() para o botão de confirmação.
  • Ícone “etiqueta” no header da coluna NÃO é vincular etiqueta. É o botão de menu (Editar/Excluir). Vincular etiqueta acontece dentro do modal Editar coluna, no autocomplete “Vincular etiqueta (opcional)”.
  • Coordenada hardcoded do ícone de menu da coluna varia. Use o helper colIconPos(name) em § 3.3.
  • O autocomplete de etiquetas acumula itens da sidebar como “ruído” se você capturar com [role="option"], li. Filtre por “etiquetas reais” excluindo Dashboard, Kanban's, Assistentes IA, Fluxos de Conversa, Transmissão, Base de conhecimento, Audiência, Suporte.
  • Input de Valor é mascarado em centavos. Sempre testar o output após salvar.
  • Cartões com mesma etiqueta de uma coluna NÃO migram automaticamente quando você vincula a etiqueta a uma coluna depois. Para “importar” leads pré-existentes, use Criar cartão › Tipo: Por etiqueta.
  • Right-click é nativo do navegador, não há context menu da aplicação.
  • Automação etiqueta→coluna validada empiricamente em 2026-05-09 (sessão TESTE-IA com Fulano Exemplo):
    • Adicionar etiqueta no Bate Papo (/live-chat-whatsapp, click no input “Etiquetas” ~880,103) move o card automaticamente da coluna atual para a coluna que tem aquela etiqueta vinculada — desde que a coluna já exista.
    • “1 lead, 1 coluna” confirmado — não duplica.
    • “Última etiqueta vence” CONFIRMADO: lead que já está em coluna A migra para coluna B ao receber a etiqueta de B. Sequência testada: Manual → (aplica Cliente quente) → Auto → (aplica Lead interessado) → Lead int.
    • Remover a etiqueta da coluna atual NÃO faz o card voltar para outra coluna automaticamente mesmo que o contato ainda tenha etiquetas vinculadas a outras colunas. O card sai do Kanban; precisa re-aplicar etiqueta ou mover manualmente para reapresentar.
    • Coluna criada DEPOIS que o lead já tem a etiqueta correspondente NÃO captura retroativamente — usar Criar cartão › Tipo: Por etiqueta.
    • Excluir coluna com cartões apaga os cartões; contatos sobrevivem no Bate Papo/Audiência.

7. Detecção de sucesso

  • Após criar coluna: a coluna nova aparece à direita das existentes; contador inicia em 0.
  • Após criar cartão Tipo: Contato: contador da coluna destino incrementa em 1; nome do contato visível no card.
  • Após criar cartão Tipo: Por etiqueta: contador incrementa em N (número de contatos com a etiqueta — pode ser zero se nenhum tinha).
  • Após drag-and-drop: contador da coluna origem decrementa, da destino incrementa; soma de R$ atualiza.
  • Após salvar valor: “R$ X,YY” aparece tanto no card quanto no header da coluna.

8. Recuperação de erros

  • Modal não abriu após click em “Criar coluna” / “Criar cartão”: confirme que está em /kanban/<uuid> e não em /kanban-list.
  • “Criar cartão” desabilitado: o Kanban precisa ter pelo menos 1 coluna. Crie uma coluna primeiro.
  • opt count == 0 no autocomplete de etiquetas: o nome da etiqueta digitado não bateu exatamente. As etiquetas têm casing sensível (“Cliente quente” ≠ “Cliente Quente”). Liste primeiro:
    await page.locator('input[placeholder="Vincular etiqueta (opcional)"]').click();
    await page.waitForTimeout(800);
    // capturar [role="option"] sem filtro de texto pra ver casing real
  • + Novo card dentro da coluna sumiu: scroll horizontal — o + Novo card aparece sempre no fundo de cada coluna; se a coluna só tem placeholder (“Nenhum card adicionado”), o botão fica abaixo do placeholder.

9. Cleanup recomendado

Toda sessão de auditoria que cria estado deve fechar com cleanup:

// 1. apagar todos os cartões (se necessário) — ou só apagar o Kanban inteiro
// 2. voltar pra /kanban-list
await page.goto('https://app.sagazchat.com/kanban-list');
await page.waitForTimeout(2000);
// 3. abrir kebab do TESTE-IA e Excluir
const rowY = await page.evaluate((n) => {
  const el = Array.from(document.querySelectorAll('p, span'))
    .find(e => (e.innerText || '').trim() === n);
  return el ? Math.round(el.getBoundingClientRect().y + 12) : null;
}, 'TESTE-IA');
await page.mouse.click(1380, rowY);
await page.waitForTimeout(800);
await page.locator('text=Excluir').click();
await page.waitForTimeout(1500);
await page.getByRole('button', { name: /^Ok$/ }).click();
await page.waitForTimeout(3000);

10. Criar coluna via API [validado 2026-05-15]

Alternativa mais rapida que UI para setup de contas novas. Requer o UUID do board.

10.1 Descobrir o UUID do board

Opcao A — capturar da URL navegando pela UI:

/kanban-list → clicar no board → URL vira /kanban/<uuid>

Opcao B — interceptar o request GET /kanban no browser.

10.2 Endpoint

POST https://backend.sagazchat.com/kanban/column
Authorization: Bearer <jwt>
Content-Type: application/json

{
  "name": "Cliente Ativo",
  "position": 7,
  "uuid": "95e69c4c-446c-46d0-a3cb-9661a33efaae",
  "tagLinked": 776
}
  • uuid — UUID do board (da URL), nao o id numerico. Campo obrigatorio; sem ele retorna 500 sem mensagem util.
  • position — usar colunas_existentes.length + 1 para adicionar ao final.
  • tagLinked — id da etiqueta (opcional; omitir se nao vincular).

Resposta:

{
  "id": 152,
  "name": "Cliente Ativo",
  "position": 7,
  "companyId": 229,
  "tagLinked": 776,
  "kanbanListId": 152
}

10.3 Estrutura de dois niveis (importante)

O Kanban do Sagazchat tem dois niveis distintos:

  • /kanban-list — lista de boards da conta (ex: “Operacao Exemplo”, “Vendas”)
  • /kanban/<uuid> — dentro de um board especifico, onde estao as colunas

O botao “Criar coluna” so existe dentro do board (/kanban/<uuid>). Navegar para /kanban sem UUID nao funciona para criacao.

Da mesma forma, POST /kanban/column precisa do uuid do board — nao e uma coluna global.

11. Como iniciar nova auditoria

  1. Garantir sessão CDP viva (scripts/sagaz-session.mjs ou re-iniciar).
  2. Criar Kanban TESTE-IA novo (não reutilizar — UUIDs antigos podem estar marcados, e cartões/colunas ficam de sessões antigas).
  3. Mexer livremente dentro do TESTE-IA.
  4. Deletar o TESTE-IA no fim — não deixar resíduo no CRM real.