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.
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.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.