Criar um dashboard SaaS confiável com Claude Code
Guia prático de KPIs, SQL, UI Next.js, acessibilidade, permissões e revisão para dashboards SaaS.
Um dashboard SaaS precisa ser confiável antes de ser bonito
Um dashboard SaaS não é apenas uma página com gráficos. Ele é a mesa de trabalho onde liderança, finanças, customer success, vendas e produto decidem o que corrigir primeiro. Se o MRR não mostra moeda, a taxa de ativação usa denominador incerto ou o SLA não informa fuso horário, o time passa a decidir com base frágil.
Claude Code acelera o layout, mas precisa de uma especificação confiável. Aqui, KPI significa indicador-chave de desempenho, API contract é o acordo de dados entre backend e frontend, e stale data são dados antigos demais para uma decisão segura. O fluxo cobre definição de KPI, contrato API, agregação SQL, UI React/Next.js, acessibilidade de gráficos, filtros, estados de carregamento e erro, limites de função e revisão.
flowchart LR
A["Definição de KPI"] --> B["API contract"]
B --> C["Agregação SQL"]
C --> D["Next.js API"]
D --> E["React UI"]
E --> F["Permissões e revisão"]
F --> A
Defina as condições de confiança
Antes de pedir cartões e gráficos ao Claude Code, defina unidade, período, fuso horário, escopo de permissão e frescor dos dados.
| Área | Dashboard fraco | Dashboard confiável |
|---|---|---|
| Unidade | Mostra 123,456 | Mostra JPY, USD, users ou % |
| Período | Diz “este mês” | Mostra 2026-05-01 até 2026-05-31 |
| Fuso horário | Usa padrão do banco | Mostra Asia/Tokyo ou UTC na API e UI |
| Permissões | Esconde cards no React | Valida tenant e role na API e no SQL |
| Frescor | Dados antigos parecem normais | Mostra generatedAt e estado stale |
| Explicação | Só há gráfico | Inclui fórmula, período comparado e exclusões |
Três usos práticos aparecem quase sempre. Liderança acompanha MRR, ARR, churn, ARPA e mix de planos com moeda clara. Produto acompanha ativação, trial conversion e adoção de funcionalidades com denominador explícito. Customer success acompanha tickets abertos, violações de SLA, contas de risco e filas por responsável sem cruzar limites de acesso. Finanças acrescenta faturas pagas, reembolsos, vencidos e receita por plano.
API contract pronto para copiar
Defina a resposta de /api/dashboard/summary antes da UI. Este JSON pode ser usado como mock, fixture ou teste frontend.
{
"meta": {
"tenantId": "tenant_123",
"dateRange": {
"from": "2026-05-01",
"to": "2026-05-31"
},
"timezone": "Asia/Tokyo",
"generatedAt": "2026-06-01T09:00:00+09:00",
"staleAfterMinutes": 60,
"permissions": ["dashboard:read", "finance:read"]
},
"metrics": [
{
"id": "mrr",
"label": "MRR",
"unit": "JPY",
"current": 4820000,
"previous": 4510000,
"deltaPct": 6.87,
"formula": "paid monthly subscription revenue excluding tax and refunds"
},
{
"id": "activation_rate",
"label": "Activation rate",
"unit": "percent",
"current": 42.3,
"previous": 39.8,
"deltaPct": 2.5,
"formula": "activated accounts divided by new trial accounts"
}
],
"series": [
{ "date": "2026-05-01", "mrr": 4380000, "activationRate": 37.5 },
{ "date": "2026-05-08", "mrr": 4510000, "activationRate": 39.1 },
{ "date": "2026-05-15", "mrr": 4620000, "activationRate": 40.4 },
{ "date": "2026-05-22", "mrr": 4740000, "activationRate": 41.8 },
{ "date": "2026-05-31", "mrr": 4820000, "activationRate": 42.3 }
]
}
O bloco meta é essencial. A interface não deve adivinhar período, fuso, permissões ou validade. Para rotas e componentes no App Router, use a documentação oficial do Next.js App Router. Para componentes, estado e renderização condicional, consulte React Learn.
SQL com fronteiras explícitas
Este exemplo PostgreSQL assume uma tabela invoices com tenant_id, status, amount_cents, paid_at e currency. A data final é exclusiva para evitar bugs de fim de mês e fuso horário.
WITH params AS (
SELECT
'tenant_123'::text AS tenant_id,
'2026-05-01'::date AS from_date,
'2026-06-01'::date AS exclusive_to_date,
'Asia/Tokyo'::text AS report_timezone
),
paid_invoices AS (
SELECT
date_trunc('day', paid_at AT TIME ZONE params.report_timezone)::date AS paid_day,
amount_cents,
currency
FROM invoices
CROSS JOIN params
WHERE invoices.tenant_id = params.tenant_id
AND invoices.status = 'paid'
AND invoices.paid_at >= params.from_date AT TIME ZONE params.report_timezone
AND invoices.paid_at < params.exclusive_to_date AT TIME ZONE params.report_timezone
),
daily AS (
SELECT
paid_day,
currency,
SUM(amount_cents) / 100.0 AS revenue,
COUNT(*) AS paid_invoice_count
FROM paid_invoices
GROUP BY paid_day, currency
)
SELECT
paid_day,
currency,
revenue,
paid_invoice_count,
SUM(revenue) OVER (PARTITION BY currency ORDER BY paid_day) AS cumulative_revenue
FROM daily
ORDER BY paid_day, currency;
Armadilhas comuns: usar 23:59:59 como fim do período, tirar média de percentuais diários, esquecer filtro de tenant em consulta de gráfico e misturar reembolso com receita paga. Peça ao Claude Code para revisar isso como correção de negócio, não como estilo.
Tipos TypeScript para proteger o contrato
export type MetricUnit = "JPY" | "USD" | "users" | "percent" | "count";
export type DashboardMetric = {
id: "mrr" | "activation_rate" | "trial_conversion" | "support_sla";
label: string;
unit: MetricUnit;
current: number;
previous: number;
deltaPct: number;
formula: string;
};
export type DashboardPoint = {
date: string;
mrr: number;
activationRate: number;
};
export type DashboardPayload = {
meta: {
tenantId: string;
dateRange: {
from: string;
to: string;
};
timezone: string;
generatedAt: string;
staleAfterMinutes: number;
permissions: string[];
};
metrics: DashboardMetric[];
series: DashboardPoint[];
};
UI React com loading, erro, vazio e stale
Este client component cobre loading, 403, erro de API, dados vazios, stale data, cards KPI e gráfico com tabela fallback. Para acessibilidade, use MDN Web Accessibility e a wiki de acessibilidade do Recharts.
"use client";
import { useEffect, useMemo, useState } from "react";
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import type { DashboardPayload, DashboardMetric } from "./dashboard-types";
type LoadState =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "empty" }
| { status: "ready"; data: DashboardPayload };
const money = new Intl.NumberFormat("ja-JP", {
style: "currency",
currency: "JPY",
maximumFractionDigits: 0
});
function formatMetric(metric: DashboardMetric) {
if (metric.unit === "JPY") return money.format(metric.current);
if (metric.unit === "percent") return `${metric.current.toFixed(1)}%`;
return metric.current.toLocaleString("pt-BR");
}
function isStale(data: DashboardPayload) {
const generated = new Date(data.meta.generatedAt).getTime();
const limit = data.meta.staleAfterMinutes * 60 * 1000;
return Date.now() - generated > limit;
}
export default function DashboardPage() {
const [state, setState] = useState<LoadState>({ status: "loading" });
useEffect(() => {
const controller = new AbortController();
async function loadDashboard() {
try {
setState({ status: "loading" });
const response = await fetch(
"/api/dashboard/summary?from=2026-05-01&to=2026-05-31&timezone=Asia/Tokyo",
{ signal: controller.signal }
);
if (response.status === 403) {
setState({ status: "error", message: "Você não tem permissão para ver este dashboard." });
return;
}
if (!response.ok) {
throw new Error(`Dashboard API failed: ${response.status}`);
}
const data = (await response.json()) as DashboardPayload;
setState(data.metrics.length === 0 ? { status: "empty" } : { status: "ready", data });
} catch (error) {
if (!controller.signal.aborted) {
setState({ status: "error", message: error instanceof Error ? error.message : "Unknown dashboard error." });
}
}
}
loadDashboard();
return () => controller.abort();
}, []);
if (state.status === "loading") return <section aria-busy="true" className="p-6">Carregando dashboard...</section>;
if (state.status === "empty") return <section className="p-6">Não há KPIs para este filtro.</section>;
if (state.status === "error") {
return (
<section role="alert" className="p-6">
<h2 className="text-lg font-semibold">Não foi possível carregar o dashboard</h2>
<p>{state.message}</p>
<button className="mt-4 rounded bg-slate-900 px-4 py-2 text-white" onClick={() => location.reload()}>
Tentar novamente
</button>
</section>
);
}
return <DashboardContent data={state.data} />;
}
function DashboardContent({ data }: { data: DashboardPayload }) {
const stale = useMemo(() => isStale(data), [data]);
return (
<main className="space-y-6 p-6">
<header className="space-y-2">
<h1 className="text-2xl font-bold">SaaS KPI Dashboard</h1>
<p className="text-sm text-slate-600">
{data.meta.dateRange.from} até {data.meta.dateRange.to}, fuso {data.meta.timezone}.
Gerado em {data.meta.generatedAt}.
</p>
{stale && (
<p role="status" className="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
Estes dados estão vencidos. Recalcule antes de decidir.
</p>
)}
</header>
<section className="grid gap-4 md:grid-cols-2 lg:grid-cols-4" aria-label="Key metrics">
{data.metrics.map((metric) => (
<article key={metric.id} className="rounded border bg-white p-4">
<p className="text-sm text-slate-500">{metric.label}</p>
<p className="mt-2 text-2xl font-bold">{formatMetric(metric)}</p>
<p className="text-sm text-slate-600">Previous period {metric.deltaPct >= 0 ? "+" : ""}{metric.deltaPct.toFixed(2)}%</p>
<p className="mt-2 text-xs text-slate-500">Formula: {metric.formula}</p>
</article>
))}
</section>
<section className="rounded border bg-white p-4">
<h2 className="text-lg font-semibold">MRR trend</h2>
<ResponsiveContainer width="100%" height={320}>
<LineChart data={data.series} accessibilityLayer aria-label="MRR trend chart">
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis tickFormatter={(value) => money.format(Number(value))} />
<Tooltip formatter={(value) => money.format(Number(value))} />
<Line type="monotone" dataKey="mrr" stroke="#2563eb" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
<table className="sr-only">
<caption>MRR trend table</caption>
<thead><tr><th>Date</th><th>MRR</th><th>Activation rate</th></tr></thead>
<tbody>{data.series.map((point) => <tr key={point.date}><td>{point.date}</td><td>{point.mrr}</td><td>{point.activationRate}</td></tr>)}</tbody>
</table>
</section>
</main>
);
}
Não dependa só de cor. Alta e queda precisam de rótulos, valores, período comparado e tabela. Se a UI filtra por plano, região, responsável ou data, URL, API e SQL precisam receber os mesmos parâmetros.
Prompt de revisão para Claude Code
Os Claude Code common workflows funcionam melhor em ciclos pequenos de investigação, edição, teste e revisão.
You are reviewing a SaaS KPI dashboard implementation.
Check these files:
- app/api/dashboard/summary/route.ts
- app/dashboard/page.tsx
- lib/dashboard-types.ts
- sql/dashboard-summary.sql
Review priorities:
1. KPI correctness: units, formulas, date range, timezone, and previous-period comparison.
2. Trust signals: generatedAt, staleAfterMinutes, empty state, loading state, error state.
3. Security: tenant isolation, role boundaries, finance-only metrics, API-side authorization.
4. Accessibility: chart labels, non-color-only meaning, keyboard navigation, table fallback.
5. Maintainability: duplicated formatter logic, unsafe casts, missing tests.
Return findings as P0, P1, or P2. Include file paths, exact code references, and a suggested fix.
Do not rewrite the whole dashboard unless a finding requires it.
Para continuar, leia visualização de dados com Claude Code e o guia de RBAC. Para transformar isso em padrão de equipe, ClaudeCodeLab oferece templates Claude Code e treinamento e consultoria para dicionários KPI, prompts de revisão e regras do repositório.
Ao testar este fluxo com dados SaaS de exemplo do ClaudeCodeLab, o maior ganho foi não começar pela tela. Quando o contrato passou a incluir fuso, horário de geração, stale status e permissões, a revisão do Claude Code ficou objetiva. A UI evolui mais rápido quando as condições de confiança vêm antes do acabamento visual.
Artigos relacionados
Visualização de dados com Claude Code: dashboard de receita
Implemente visualização de dados com Claude Code: Recharts, D3, CSV, acessibilidade e checks com Playwright.
Como implementar paginação com Claude Code, React e Next.js
Guia prático de paginação com Claude Code: estado na URL, API, acessibilidade e testes de borda.
Criar um boilerplate SaaS com Claude Code: Next.js, auth, billing, tenants e testes
Use Claude Code para criar um starter SaaS pago com Next.js, auth, billing, tenants, auditoria, testes e checklist.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.