Use Cases (Actualizado: 1/6/2026)

Construir un dashboard SaaS confiable con Claude Code

Guía práctica para definir KPIs, SQL, UI Next.js, accesibilidad, permisos y revisión de dashboards SaaS.

Construir un dashboard SaaS confiable con Claude Code

Un dashboard SaaS debe ser confiable antes de verse bien

Un dashboard SaaS no es una página con gráficos bonitos. Es la superficie donde dirección, finanzas, customer success, ventas y producto deciden qué corregir primero. Si el MRR no muestra moneda, la tasa de activación usa un denominador ambiguo o el SLA ignora la zona horaria, el equipo toma decisiones con datos frágiles.

Claude Code puede acelerar el layout, pero necesita una especificación clara. En este artículo, KPI significa indicador clave de rendimiento, API contract es el acuerdo de datos entre backend y frontend, y stale data son datos demasiado antiguos para decidir. Veremos definición de KPIs, contrato API, agregación SQL, UI con React/Next.js, accesibilidad de gráficos, filtros, estados de carga y error, límites por rol y un ciclo de revisión.

flowchart LR
  A["Definición KPI"] --> B["API contract"]
  B --> C["Agregación SQL"]
  C --> D["Next.js API"]
  D --> E["React UI"]
  E --> F["Permisos y revisión"]
  F --> A

Define primero las condiciones de confianza

Antes de pedir tarjetas y gráficos, define qué hace que el número sea confiable: unidad, rango de fechas, zona horaria, alcance de permisos y frescura.

ÁreaDashboard débilDashboard confiable
UnidadMuestra 123,456Muestra JPY, USD, users o %
FechasDice “este mes”Muestra 2026-05-01 a 2026-05-31
Zona horariaUsa el valor por defecto de la DBMuestra Asia/Tokyo o UTC en API y UI
PermisosOculta tarjetas en ReactValida tenant y rol en API y SQL
FrescuraLos datos viejos parecen normalesMuestra generatedAt y estado stale
ExplicaciónSolo hay gráficoIncluye fórmula, periodo comparado y exclusiones

Los primeros casos de uso suelen ser estos: KPIs ejecutivos como MRR, ARR, churn, ARPA y mezcla de planes; KPIs de producto como activación, conversión de prueba a pago y adopción de funciones; KPIs de customer success como tickets abiertos, SLA incumplido y cuentas en riesgo; y KPIs financieros como facturas pagadas, reembolsos, deuda vencida e ingresos por plan.

API contract listo para copiar

Fija la respuesta de /api/dashboard/summary antes de construir la UI. Este JSON sirve como mock, fixture o prueba de 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 }
  ]
}

La clave está en meta. La UI no debe adivinar periodo, zona horaria, permisos ni frescura. Para rutas y componentes en App Router, usa la documentación oficial de Next.js App Router. Para componentes, renderizado condicional y estado, vuelve a React Learn.

SQL con límites explícitos

Este ejemplo PostgreSQL asume una tabla invoices con tenant_id, status, amount_cents, paid_at y currency. El final del periodo es exclusivo para evitar errores de milisegundos y zona horaria.

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;

Los fallos típicos son usar 23:59:59 como cierre, promediar porcentajes diarios en lugar de recalcular numerador y denominador, olvidar el filtro de tenant y mezclar reembolsos con ingresos pagados. Pide a Claude Code que revise estos puntos como errores de negocio, no como estilo.

Tipos TypeScript para proteger el 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 con carga, error, vacío y stale

Este client component cubre carga, 403, errores de API, datos vacíos, stale data, tarjetas KPI y un gráfico con tabla fallback. Revisa accesibilidad con MDN Web Accessibility y la wiki de accesibilidad de 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("es-ES");
}

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: "No tienes permiso 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">Cargando dashboard...</section>;
  if (state.status === "empty") return <section className="p-6">No hay KPIs para este filtro.</section>;
  if (state.status === "error") {
    return (
      <section role="alert" className="p-6">
        <h2 className="text-lg font-semibold">No se pudo cargar el dashboard</h2>
        <p>{state.message}</p>
        <button className="mt-4 rounded bg-slate-900 px-4 py-2 text-white" onClick={() => location.reload()}>
          Reintentar
        </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} a {data.meta.dateRange.to}, zona horaria {data.meta.timezone}.
          Generado en {data.meta.generatedAt}.
        </p>
        {stale && (
          <p role="status" className="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
            Estos datos están vencidos. Recalcula 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>
  );
}

No transmitas significado solo con color. Las subidas y bajadas necesitan etiquetas, números, periodo comparado y una tabla accesible. Si la UI filtra por plan, región, owner o fecha, el URL, la API y SQL deben usar los mismos parámetros.

Prompt de revisión para Claude Code

El flujo recomendado en Claude Code common workflows funciona mejor si investigas, editas, pruebas y revisas en ciclos pequeños.

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 ampliar el tema, lee visualización de datos con Claude Code y la guía de RBAC. Si quieres convertir este proceso en una práctica de equipo, ClaudeCodeLab ofrece plantillas de Claude Code y formación y consultoría para diccionarios KPI, prompts de revisión y reglas específicas del repositorio.

Al probar este flujo con datos SaaS de ejemplo de ClaudeCodeLab, el mayor beneficio fue no empezar por la pantalla. Cuando el contrato incluyó zona horaria, hora de generación, estado stale y permisos, la revisión de Claude Code se volvió concreta. La UI mejora más rápido cuando las condiciones de confianza están fijadas desde el principio.

#Claude Code #dashboard SaaS #React #Next.js #visualización de datos #Recharts
Gratis

PDF gratis: cheatsheet de Claude Code

Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.

Cuidamos tus datos y no enviamos spam.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.