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.

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.