Ein vertrauenswürdiges SaaS-Dashboard mit Claude Code bauen
Praxisleitfaden für KPI-Definition, SQL, Next.js UI, Barrierefreiheit, Rollen und Review von SaaS-Dashboards.
Ein SaaS-Dashboard muss zuerst verlässlich sein
Ein SaaS-Dashboard ist nicht nur eine Seite mit hübschen Diagrammen. Es ist die Arbeitsfläche, auf der Geschäftsführung, Finance, Customer Success, Sales und Produkt entscheiden, was als Nächstes verbessert wird. Wenn MRR ohne Währung erscheint, die Aktivierungsrate einen unklaren Nenner hat oder SLA-Zahlen die Zeitzone verschweigen, wird das Dashboard zur Risikoquelle.
Claude Code kann Layouts schnell erzeugen, braucht aber klare Vertrauensbedingungen. KPI bedeutet hier Leistungskennzahl, API contract ist die Datenvereinbarung zwischen Backend und Frontend, und stale data sind Daten, die für Entscheidungen zu alt sein können. Dieser Leitfaden führt durch KPI-Definition, API contract, SQL-Aggregation, React/Next.js UI, Chart-Barrierefreiheit, Filter, Lade- und Fehlerzustände, Rollengrenzen und Review-Schleife.
flowchart LR
A["KPI-Definition"] --> B["API contract"]
B --> C["SQL-Aggregation"]
C --> D["Next.js API"]
D --> E["React UI"]
E --> F["Berechtigungen und Review"]
F --> A
Vertrauensbedingungen vor dem UI festlegen
Bevor Claude Code Karten und Charts baut, müssen Einheit, Zeitraum, Zeitzone, Berechtigungsumfang und Datenfrische feststehen.
| Bereich | Dünnes Dashboard | Vertrauenswürdiges Dashboard |
|---|---|---|
| Einheit | Zeigt nur 123,456 | Zeigt JPY, USD, users oder % |
| Zeitraum | Sagt “diesen Monat” | Zeigt 2026-05-01 bis 2026-05-31 |
| Zeitzone | Nutzt DB-Default | Zeigt Asia/Tokyo oder UTC in API und UI |
| Rechte | Versteckt Karten in React | Prüft Tenant und Rolle in API und SQL |
| Frische | Alte Daten sehen normal aus | Zeigt generatedAt und stale Status |
| Erklärung | Nur Chart | Enthält Formel, Vergleichszeitraum und Ausschlüsse |
Typische Anwendungsfälle sind mindestens diese drei: Management betrachtet MRR, ARR, Churn, ARPA und Plan-Mix mit klarer Währung und Monatslogik. Produktteams prüfen Aktivierung, Trial Conversion, Feature Adoption und Kohorten mit eindeutigem Nenner. Customer Success sieht offene Tickets, SLA-Verstöße, Risikokonten und Owner-Queues mit strikten Rollengrenzen. Finance ergänzt bezahlte Rechnungen, Refunds, überfällige Beträge und Umsatz nach Plan.
API contract zum Kopieren
Fixieren Sie die Antwort von /api/dashboard/summary, bevor die Oberfläche entsteht. Dieses JSON funktioniert als Mock, Fixture oder Frontend-Testdaten.
{
"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 }
]
}
meta ist nicht optional. Die UI sollte Zeitraum, Zeitzone, Rechte und Datenfrische nicht erraten. Für Routing und Komponenten im App Router nutzen Sie die offiziellen Next.js App Router docs. Für Komponenten, State und bedingtes Rendering ist React Learn die solide Basis.
SQL-Aggregation mit klaren Grenzen
Dieses PostgreSQL-Beispiel geht von einer Tabelle invoices mit tenant_id, status, amount_cents, paid_at und currency aus. Das Periodenende ist exklusiv, damit Monatsende, Millisekunden und Zeitzonen sauber bleiben.
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;
Konkrete Fallen: 23:59:59 als Monatsende, Durchschnitt aus Tagesquoten, fehlender Tenant-Filter in Chart-Abfragen und Refunds als bezahlter Umsatz. Lassen Sie Claude Code diese Punkte als fachliche Korrektheit prüfen, nicht als Stilfragen.
TypeScript-Typen für den Vertrag
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[];
};
React UI mit Loading, Error, Empty und Stale
Diese client component behandelt Ladezustand, 403, API-Fehler, leere Daten, stale data, KPI-Karten und ein Diagramm mit Tabellen-Fallback. Für Barrierefreiheit sind MDN Web Accessibility und die Recharts accessibility wiki die Referenzen.
"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("de-DE");
}
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: "Sie haben keine Berechtigung für dieses 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">Dashboard wird geladen...</section>;
if (state.status === "empty") return <section className="p-6">Für diesen Filter sind keine KPIs verfügbar.</section>;
if (state.status === "error") {
return (
<section role="alert" className="p-6">
<h2 className="text-lg font-semibold">Dashboard konnte nicht geladen werden</h2>
<p>{state.message}</p>
<button className="mt-4 rounded bg-slate-900 px-4 py-2 text-white" onClick={() => location.reload()}>
Erneut versuchen
</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} bis {data.meta.dateRange.to}, Zeitzone {data.meta.timezone}.
Generiert um {data.meta.generatedAt}.
</p>
{stale && (
<p role="status" className="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
Diese Daten sind veraltet. Aggregieren Sie neu, bevor Sie entscheiden.
</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>
);
}
Verlassen Sie sich nicht nur auf Farbe. Steigende und fallende Werte brauchen Labels, Zahlen, Vergleichszeitraum und Tabellen-Fallback. Wenn die UI nach Plan, Region, Owner oder Datum filtert, müssen URL, API und SQL dieselben Parameter nutzen.
Review-Prompt für Claude Code
Die offiziellen Claude Code common workflows funktionieren am besten in kleinen Schleifen aus Untersuchung, Änderung, Test und Review.
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.
Passend dazu sind Datenvisualisierung mit Claude Code und der RBAC-Leitfaden. Wenn dieser Ablauf Teamstandard werden soll, bietet ClaudeCodeLab Claude Code Templates sowie Training und Beratung für KPI-Wörterbücher, Review-Prompts und Repository-Regeln.
Beim Test mit ClaudeCodeLab-Beispieldaten war der wichtigste Effekt, nicht mit dem Bildschirm zu starten. Sobald der Contract Zeitzone, Generierungszeit, stale Status und Rechte enthielt, wurden die Claude-Code-Reviews konkret. Das Dashboard entsteht schneller, wenn die Vertrauensbedingungen vor dem visuellen Feinschliff feststehen.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Workflow von Obsidian zu CLAUDE.md
Obsidian-Arbeitsnotizen in CLAUDE.md-Betriebsnotizen verwandeln und Kontext nicht ständig neu erklären.
Claude Code Revenue CTA Routing: Artikel zu PDF, Gumroad und Beratung führen
Ein Claude-Code-Ablauf, der Leser nach Absicht zu Gratis-PDF, Gumroad oder Beratung führt.
Claude-Code-Team-Handoff-Regeln: Belege, Berechtigungen, Rollback und Umsatzpfade
Ein praktisches Claude-Code-Handoff für Review-Belege, Berechtigungen, Rollback, Gratis-PDF, Gumroad und Beratung.