Dashboard de atendimento no WhatsApp com Node.js e Chart.js

Um dashboard de atendimento no WhatsApp responde às perguntas que todo gestor faz e quase nenhum consegue responder olhando a tela do celular: quanto tempo o cliente espera pela primeira resposta, quantas conversas estão paradas na fila agora e quantas foram resolvidas hoje. Com Node.js, um banco relacional e o Chart.js, dá para ter esses números em uma tela em poucas horas.

Este tutorial segue o caminho completo: quais métricas medir, como receber os eventos do WhatsApp por webhook e gravá-los no banco, como calcular os indicadores com SQL, como expor uma API em Express, como desenhar os gráficos e como disparar alertas quando o SLA estoura. O código é propositalmente simples e serve de base para qualquer provedor de API de WhatsApp.

Quais métricas medir: tempo de 1ª resposta, TMA, fila e resolução

Antes do código, é preciso decidir o que entra na tela. Dashboards de atendimento falham quando mostram vinte números e ninguém sabe qual importa. Quatro indicadores cobrem a maior parte das decisões:

  • Tempo de primeira resposta (TPR): intervalo entre a primeira mensagem do cliente e a primeira resposta humana. É o número que o cliente sente.
  • Tempo médio de atendimento (TMA): duração da conversa, da abertura ao encerramento.
  • Conversas em fila: quantas estão abertas sem nenhuma resposta neste momento. É o indicador operacional em tempo real.
  • Taxa de resolução: proporção de conversas encerradas como resolvidas, sem reabertura em um prazo (24 horas, por exemplo).

Para cada indicador, defina uma meta antes de olhar os dados, para não ajustar a régua ao resultado. O número certo depende do volume e do tamanho do time; o que importa é que gestor e atendentes conheçam a meta e vejam o mesmo painel, com o mesmo cálculo por trás.

Entre eles, o primeiro merece destaque. Como mostra o artigo da PulseOut sobre tempo de resposta no WhatsApp, é a métrica que mais influencia a percepção de qualidade, porque o cliente compara a sua demora com a de uma conversa pessoal, não com a de um e-mail. Use mediana e percentil 90 além da média: a média esconde os casos em que alguém esperou uma hora.

Se o time tiver maturidade para mais, o guia de indicadores de atendimento ao cliente lista os KPIs seguintes (satisfação, reabertura, volume por atendente) e como evitar que virem metas distorcidas. Comece pelos quatro acima; adicione os outros quando os primeiros estiverem confiáveis.

Coletando eventos via webhook e salvando no banco

O dashboard só é bom se os dados de origem forem. A fonte são os eventos que a API de WhatsApp envia para a sua aplicação: mensagem recebida, mensagem enviada, status de entrega e leitura. Na prática, você registra uma URL pública no provedor, e ele faz um POST nela a cada evento. O artigo sobre webhooks de WhatsApp e SMS em Node.js detalha o registro da URL, o formato dos eventos e a validação de origem; aqui o foco é o que fazer com eles.

Para calcular as métricas, bastam duas tabelas: uma de conversas e uma de mensagens. O exemplo usa PostgreSQL, mas o SQL é quase todo padrão.

CREATE TABLE conversas (
  id                   TEXT PRIMARY KEY,          -- telefone do cliente + data de abertura
  cliente              TEXT NOT NULL,
  atendente_id         TEXT,
  aberta_em            TIMESTAMPTZ NOT NULL,
  primeira_resposta_em TIMESTAMPTZ,
  encerrada_em         TIMESTAMPTZ,
  resolvida            BOOLEAN NOT NULL DEFAULT FALSE
);

CREATE TABLE mensagens (
  id           BIGSERIAL PRIMARY KEY,
  conversa_id  TEXT NOT NULL REFERENCES conversas(id),
  direcao      TEXT NOT NULL CHECK (direcao IN ('entrada', 'saida')),
  autor        TEXT,
  texto        TEXT,
  criada_em    TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_mensagens_conversa ON mensagens (conversa_id, criada_em);
CREATE INDEX idx_conversas_aberta ON conversas (aberta_em);

O webhook normaliza o evento do provedor (os nomes dos campos variam) para o formato interno e grava. A regra de negócio está em dois pontos: uma mensagem de entrada sem conversa aberta cria uma nova conversa, e a primeira mensagem de saída preenche primeira_resposta_em.

// webhook.js
import express from 'express';
import pg from 'pg';

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const app = express();
app.use(express.json());

// Converte o payload do provedor para o formato interno (ajuste os campos ao seu)
function normalizar(evento) {
  return {
    cliente: String(evento.from ?? evento.phone ?? '').replace(/\D/g, ''),
    direcao: evento.fromMe ? 'saida' : 'entrada',
    autor: evento.fromMe ? (evento.agentId ?? 'sistema') : 'cliente',
    texto: evento.text ?? evento.body ?? '',
    criadaEm: evento.timestamp ? new Date(evento.timestamp * 1000) : new Date(),
  };
}

app.post('/webhook/whatsapp', async (req, res) => {
  if (req.get('x-webhook-secret') !== process.env.WEBHOOK_SECRET) return res.sendStatus(401);
  res.sendStatus(200); // responda rápido; o provedor reenvia se demorar

  const m = normalizar(req.body);
  if (!m.cliente) return;

  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    // conversa aberta mais recente desse cliente (sem encerramento)
    const { rows } = await client.query(
      `SELECT id, primeira_resposta_em FROM conversas
       WHERE cliente = $1 AND encerrada_em IS NULL ORDER BY aberta_em DESC LIMIT 1`,
      [m.cliente]
    );

    let conversaId = rows[0]?.id;
    if (!conversaId && m.direcao === 'entrada') {
      conversaId = `${m.cliente}-${m.criadaEm.getTime()}`;
      await client.query(
        'INSERT INTO conversas (id, cliente, aberta_em) VALUES ($1, $2, $3)',
        [conversaId, m.cliente, m.criadaEm]
      );
    }
    if (!conversaId) { await client.query('COMMIT'); return; } // saída sem conversa aberta: ignora

    await client.query(
      'INSERT INTO mensagens (conversa_id, direcao, autor, texto, criada_em) VALUES ($1, $2, $3, $4, $5)',
      [conversaId, m.direcao, m.autor, m.texto, m.criadaEm]
    );

    if (m.direcao === 'saida' && !rows[0]?.primeira_resposta_em) {
      await client.query(
        'UPDATE conversas SET primeira_resposta_em = $2, atendente_id = $3 WHERE id = $1',
        [conversaId, m.criadaEm, m.autor]
      );
    }
    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    console.error('webhook:', err.message);
  } finally {
    client.release();
  }
});

app.listen(3000);

O encerramento da conversa (encerrada_em e resolvida) vem de uma ação do atendente na sua ferramenta ou de uma regra de inatividade (sem mensagens por algumas horas), executada por um job periódico. Para subir o PostgreSQL em desenvolvimento, o guia prático de Docker resolve em um comando.

Infográfico com os KPIs do dashboard de atendimento no WhatsApp: tempo de primeira resposta com mediana e p90, tempo médio de atendimento, conversas em fila, taxa de resolução e alerta quando o SLA estoura

Calculando as métricas com SQL

Com os dados no lugar, cada indicador vira uma consulta. O segredo é calcular no banco, não em JavaScript: menos dados trafegando e consultas fáceis de testar direto no cliente SQL.

-- Resumo do período (parâmetros: $1 = início, $2 = fim)
SELECT
  COUNT(*) AS conversas,
  ROUND(AVG(EXTRACT(EPOCH FROM (primeira_resposta_em - aberta_em))) / 60, 1) AS tpr_medio_min,
  ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (
        ORDER BY EXTRACT(EPOCH FROM (primeira_resposta_em - aberta_em))) / 60, 1) AS tpr_mediana_min,
  ROUND(PERCENTILE_CONT(0.9) WITHIN GROUP (
        ORDER BY EXTRACT(EPOCH FROM (primeira_resposta_em - aberta_em))) / 60, 1) AS tpr_p90_min,
  ROUND(AVG(EXTRACT(EPOCH FROM (encerrada_em - aberta_em))) / 60, 1) AS tma_min,
  ROUND(100.0 * COUNT(*) FILTER (WHERE resolvida)
        / NULLIF(COUNT(*) FILTER (WHERE encerrada_em IS NOT NULL), 0), 1) AS taxa_resolucao_pct
FROM conversas
WHERE aberta_em >= $1 AND aberta_em < $2;

-- Fila agora: abertas sem nenhuma resposta
SELECT COUNT(*) AS em_fila,
       ROUND(MAX(EXTRACT(EPOCH FROM (now() - aberta_em))) / 60, 1) AS espera_maxima_min
FROM conversas
WHERE encerrada_em IS NULL AND primeira_resposta_em IS NULL;

-- Série por hora para o gráfico de linha
SELECT date_trunc('hour', aberta_em) AS hora,
       COUNT(*) AS conversas,
       ROUND(AVG(EXTRACT(EPOCH FROM (primeira_resposta_em - aberta_em))) / 60, 1) AS tpr_medio_min
FROM conversas
WHERE aberta_em >= $1 AND aberta_em < $2
GROUP BY 1
ORDER BY 1;

Dois cuidados. Primeiro, conversas ainda sem resposta não entram no cálculo do tempo de primeira resposta (a diferença é nula e o AVG as ignora), por isso a fila é mostrada separadamente. Segundo, decida se o relógio conta o tempo fora do horário de atendimento; se não contar, a diferença precisa ser ajustada por uma tabela de expediente, o que vale a pena só depois que a versão simples estiver em uso.

API em Express para o dashboard de atendimento no WhatsApp

A API expõe três rotas, uma para cada consulta acima. Ela pode ficar no mesmo processo do webhook ou em outro; o que importa é não misturar as responsabilidades no mesmo arquivo. As consultas SQL ficam em constantes (SQL_RESUMO, SQL_FILA, SQL_SERIE) ou em arquivos .sql carregados na inicialização.

// api.js
import { Router } from 'express';
import pg from 'pg';
import { SQL_RESUMO, SQL_FILA, SQL_SERIE } from './consultas.js';

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export const api = Router();

function periodo(req) {
  const dias = Math.min(Number(req.query.dias ?? 7), 90);
  const fim = new Date();
  const inicio = new Date(fim.getTime() - dias * 24 * 60 * 60 * 1000);
  return [inicio, fim];
}

api.get('/metricas', async (req, res) => {
  const { rows } = await pool.query(SQL_RESUMO, periodo(req));
  res.json({ error: false, message: 'ok', data: rows[0] });
});

api.get('/fila', async (_req, res) => {
  const { rows } = await pool.query(SQL_FILA);
  res.json({ error: false, message: 'ok', data: rows[0] });
});

api.get('/serie', async (req, res) => {
  const { rows } = await pool.query(SQL_SERIE, periodo(req));
  res.json({ error: false, message: 'ok', data: rows });
});

// no app principal:
// app.use('/api', autenticar, api);

Proteja as rotas com autenticação (sessão do painel ou um token de serviço) e adicione um cache curto, de dez a trinta segundos, nas consultas de resumo se muita gente abrir o painel ao mesmo tempo. Com volume alto de mensagens, vale também um limite de requisições por usuário nessas rotas.

Front simples com Chart.js

Não precisa de framework. Uma página HTML, o Chart.js via CDN e um fetch a cada 30 segundos entregam um painel de parede que funciona em qualquer TV ou monitor. Se o painel crescer e virar parte de um produto, migre para um app em React; o tutorial de React 19 cobre esse caminho.

<!doctype html>
<html lang="pt-BR">
<head>
  <meta charset="utf-8">
  <title>Atendimento WhatsApp</title>
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.js"></script>
  <style>
    body { font-family: system-ui, sans-serif; background: #0b1220; color: #fff; margin: 24px; }
    .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }
    .kpi { background: #1e293b; border-radius: 12px; padding: 16px; }
    .kpi strong { display: block; font-size: 2rem; }
    .alerta { background: #7f1d1d; }
  </style>
</head>
<body>
  <div class="kpis">
    <div class="kpi"><span>1ª resposta (mediana)</span><strong id="tpr">–</strong></div>
    <div class="kpi"><span>1ª resposta (p90)</span><strong id="p90">–</strong></div>
    <div class="kpi"><span>TMA</span><strong id="tma">–</strong></div>
    <div class="kpi" id="cardFila"><span>Em fila agora</span><strong id="fila">–</strong></div>
    <div class="kpi"><span>Resolução</span><strong id="res">–</strong></div>
  </div>
  <canvas id="grafico" height="110"></canvas>

  <script>
    const $ = (id) => document.getElementById(id);
    const FILA_ALERTA = 5; // conversas sem resposta que acendem o alerta na tela
    let grafico;

    async function atualizar() {
      const [m, f, s] = await Promise.all(
        ['/api/metricas?dias=7', '/api/fila', '/api/serie?dias=7'].map((u) => fetch(u).then((r) => r.json()))
      );
      const d = m.data;
      $('tpr').textContent = `${d.tpr_mediana_min ?? '–'} min`;
      $('p90').textContent = `${d.tpr_p90_min ?? '–'} min`;
      $('tma').textContent = `${d.tma_min ?? '–'} min`;
      $('res').textContent = `${d.taxa_resolucao_pct ?? '–'}%`;
      $('fila').textContent = f.data.em_fila;
      $('cardFila').classList.toggle('alerta', Number(f.data.em_fila) >= FILA_ALERTA);

      const labels = s.data.map((p) => new Date(p.hora).toLocaleString('pt-BR', { day: '2-digit', hour: '2-digit' }));
      const dados = {
        labels,
        datasets: [
          { label: 'Conversas por hora', data: s.data.map((p) => p.conversas), yAxisID: 'y' },
          { label: '1ª resposta média (min)', data: s.data.map((p) => p.tpr_medio_min), yAxisID: 'y1' },
        ],
      };
      if (!grafico) {
        grafico = new Chart($('grafico'), {
          type: 'line',
          data: dados,
          options: { scales: { y: { position: 'left' }, y1: { position: 'right', grid: { drawOnChartArea: false } } } },
        });
      } else {
        grafico.data = dados;
        grafico.update();
      }
    }

    atualizar();
    setInterval(atualizar, 30_000);
  </script>
</body>
</html>

O gráfico de linha com dois eixos mostra volume e tempo de resposta lado a lado, o que deixa visível o padrão mais comum: a primeira resposta piora exatamente nas horas de pico. Mantenha poucos gráficos; um painel de atendimento é lido de relance, não estudado.

Dois gráficos extras costumam valer a pena quando o time cresce: um de barras com conversas por atendente no dia (para distribuir a fila) e um de barras empilhadas com conversas resolvidas e não resolvidas por dia (para enxergar tendência). Os dois saem de variações da consulta de série, trocando o date_trunc e o agrupamento.

Alertas quando o SLA estoura

Dashboard ninguém olha o dia inteiro; alerta chega onde a pessoa está. A regra mais útil é simples: se existe conversa sem primeira resposta há mais de X minutos, ou se a fila passou de Y conversas, avise o supervisor. Um job a cada minuto resolve, sem infraestrutura nova.

// alertas.js — roda a cada minuto (setInterval, node-cron ou um cron do sistema)
import pg from 'pg';
import { enviarWhatsApp } from './mensageria.js'; // função do guia de envio de SMS/WhatsApp

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const SLA_MIN = Number(process.env.SLA_PRIMEIRA_RESPOSTA_MIN ?? 10);
const FILA_MAX = Number(process.env.FILA_MAX ?? 5);
let ultimoAlerta = 0;

export async function verificarSla() {
  const { rows } = await pool.query(
    `SELECT COUNT(*)::int AS em_fila,
            COALESCE(MAX(EXTRACT(EPOCH FROM (now() - aberta_em))) / 60, 0) AS espera_max_min
     FROM conversas WHERE encerrada_em IS NULL AND primeira_resposta_em IS NULL`
  );
  const { em_fila, espera_max_min } = rows[0];
  const estourou = espera_max_min >= SLA_MIN || em_fila >= FILA_MAX;

  // evita disparar a cada minuto enquanto o problema persiste
  if (!estourou || Date.now() - ultimoAlerta < 15 * 60 * 1000) return;
  ultimoAlerta = Date.now();

  await enviarWhatsApp(
    process.env.SUPERVISOR_TELEFONE,
    `SLA de atendimento estourado: ${em_fila} conversa(s) em fila, espera máxima de ${Math.round(espera_max_min)} min.`
  );
}

setInterval(() => verificarSla().catch((e) => console.error('alerta:', e.message)), 60_000);

A função enviarWhatsApp é a mesma do guia sobre API de SMS e WhatsApp no backend; se preferir, troque por e-mail, Slack ou SMS. Três regras evitam que o alerta vire ruído: um intervalo mínimo entre avisos, um aviso de “normalizou” quando a fila zera e limites diferentes para o horário comercial e fora dele.

Perguntas frequentes

Preciso da API oficial do WhatsApp para montar o dashboard?

Não. O dashboard depende apenas de receber os eventos de mensagens por webhook, e isso existe tanto na API oficial quanto nas integrações não oficiais. O que muda é o formato do payload, resolvido pela função de normalização.

Como medir o tempo de resposta considerando o horário de atendimento?

Crie uma tabela de expediente (dia da semana, início, fim) e calcule a diferença apenas dentro dessas janelas, em uma função SQL ou no job que fecha as conversas. Comece com a versão simples e adicione essa regra quando os números fora do horário começarem a distorcer a média.

Qual banco de dados usar?

Qualquer banco relacional serve; PostgreSQL é a escolha mais comum pelos recursos de data e percentil usados aqui. SQLite funciona para um time pequeno. Evite guardar os eventos apenas em memória ou em arquivo: o histórico é o que torna as comparações possíveis.

O dashboard de atendimento no WhatsApp funciona para vários atendentes?

Sim. A coluna atendente_id permite agrupar todas as métricas por pessoa; basta adicionar GROUP BY atendente_id às consultas. Use isso para distribuir a fila e treinar, não para ranquear publicamente.

Com que frequência o painel deve atualizar?

Para um painel de parede, entre 30 e 60 segundos é suficiente e não pesa no banco. Se precisar de tempo real de verdade (fila mudando a cada segundo), troque o polling por WebSocket ou Server-Sent Events alimentados pelo próprio webhook.

Próximos passos

Suba o webhook e deixe-o gravar por alguns dias antes de mostrar qualquer número: dados de uma semana já revelam os horários de pico e o tamanho real da fila. Depois ligue a API, a página com Chart.js e, por último, o alerta de SLA. A partir daí, cada nova métrica do dashboard de atendimento no WhatsApp é uma consulta SQL e uma linha a mais no painel.