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
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 runbookbate-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 exatoKanban'scom apóstrofo. Usepage.locator('text=/^Kanban/i').first()para evitar confusão com itens “Kanban” em outros menus.
3. Mapa da UI
3.1 Tela /kanban-list
| Elemento | Coordenadas (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 Kanban | y ~150–250 (varia) | text=<NomeDoKanban> |
Kebab ⋮ da linha | x ≈ 1380, y = mesma linha | botão MUI sem aria; identificar pela posição |
Menu do kebab: Editar nome, Excluir.
3.2 Tela do Kanban (/kanban/<uuid>)
| Elemento | Posição típica | Seletor |
|---|---|---|
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–230 | ver § 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ção | Disparadora? | Observação |
|---|---|---|
| Abrir modal Criar Kanban | Não | Cancelar fecha sem efeito |
| Criar Kanban | Sim | Cria registro real; precisa cleanup depois |
| Editar nome do Kanban | Sim | Renomeia o registro |
| Excluir Kanban | Sim — destrutiva | Apaga Kanban + todas colunas/cartões. Sem undo |
| Abrir modal Criar coluna | Não | Cancelar fecha |
| Criar coluna | Sim | Cria registro real |
| Editar coluna (cancelar) | Não | — |
| Editar coluna (salvar) | Sim | Renomeia / vincula etiqueta |
| Excluir coluna | Sim — destrutiva | Apaga cartões da coluna; contatos permanecem |
| Abrir modal Novo card | Não | — |
| Criar card (Tipo: Contato) | Sim | 1 cartão |
| Criar card (Tipo: Por etiqueta) | Sim — em massa | Cria N cartões (1 por contato com a etiqueta) — atenção em etiquetas com muitos contatos |
| Doubleclick em card | Não | Apenas abre modal |
| Editar card (cancelar) | Não | — |
| Editar card (salvar) | Sim | Atualiza valor / move coluna |
| Excluir card | Sim — destrutiva | Remove o cartão; contato permanece |
| Drag-and-drop | Sim | Move o cartão para outra coluna |
| Click em Chat do modal Editar card | Sim — navega | Sai 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
100registra R$ 1,00; para R$ 100,00 digite10000.
// 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” excluindoDashboard, 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.
- Adicionar etiqueta no Bate Papo (
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 == 0no 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 carddentro da coluna sumiu: scroll horizontal — o+ Novo cardaparece 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— usarcolunas_existentes.length + 1para 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
- Garantir sessão CDP viva (
scripts/sagaz-session.mjsou re-iniciar). - Criar Kanban TESTE-IA novo (não reutilizar — UUIDs antigos podem estar marcados, e cartões/colunas ficam de sessões antigas).
- Mexer livremente dentro do TESTE-IA.
- Deletar o TESTE-IA no fim — não deixar resíduo no CRM real.