Use Cases (Diperbarui: 1/6/2026)

Membangun Dashboard SaaS Tepercaya dengan Claude Code

Panduan praktis KPI, SQL, UI Next.js, aksesibilitas, izin, dan review untuk dashboard SaaS.

Membangun Dashboard SaaS Tepercaya dengan Claude Code

Dashboard SaaS harus tepercaya sebelum terlihat bagus

Dashboard SaaS bukan sekadar halaman berisi grafik. Ini adalah ruang kerja tempat pimpinan, finance, customer success, sales, dan product menentukan masalah mana yang harus diperbaiki dulu. Jika MRR tidak menampilkan mata uang, activation rate memakai denominator yang tidak jelas, atau SLA tidak menyebut timezone, dashboard bisa mendorong keputusan yang salah.

Claude Code bisa membuat layout dengan cepat, tetapi spesifikasi kepercayaannya harus jelas. Dalam artikel ini, KPI berarti indikator kinerja utama, API contract berarti kesepakatan data antara backend dan frontend, dan stale data berarti data yang sudah terlalu lama untuk dipakai mengambil keputusan. Kita akan membahas definisi KPI, API contract, agregasi SQL, UI React/Next.js, aksesibilitas chart, filter, loading dan error states, batas role, serta review loop.

flowchart LR
  A["Definisi KPI"] --> B["API contract"]
  B --> C["Agregasi SQL"]
  C --> D["Next.js API"]
  D --> E["React UI"]
  E --> F["Izin dan review"]
  F --> A

Tetapkan syarat kepercayaan sejak awal

Sebelum meminta Claude Code membuat kartu dan grafik, tetapkan unit, rentang tanggal, timezone, cakupan permission, dan freshness data.

AreaDashboard tipisDashboard tepercaya
UnitHanya menampilkan 123,456Menampilkan JPY, USD, users, atau %
Rentang tanggalMenulis “bulan ini”Menampilkan 2026-05-01 sampai 2026-05-31
TimezoneMengikuti default databaseMenampilkan Asia/Tokyo atau UTC di API dan UI
PermissionMenyembunyikan card di ReactMengecek tenant dan role di API serta SQL
FreshnessData lama terlihat normalMenampilkan generatedAt dan status stale
PenjelasanHanya grafikMenampilkan formula, periode pembanding, dan pengecualian

Use case praktis biasanya muncul dalam empat kelompok. Leadership melihat MRR, ARR, churn, ARPA, dan komposisi plan, sehingga mata uang dan aturan alokasi bulanan harus jelas. Product melihat activation, trial conversion, feature adoption, dan cohort movement, sehingga denominator harus tertulis. Customer success melihat open tickets, pelanggaran SLA, akun berisiko, dan queue per owner, sehingga role boundary harus ketat. Finance melihat paid invoices, refunds, overdue invoices, dan revenue by plan, lalu mencocokkannya dengan export akuntansi.

API contract yang bisa langsung disalin

Tetapkan response /api/dashboard/summary sebelum membangun UI. JSON ini bisa digunakan sebagai mock, fixture, atau data test 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 }
  ]
}

Bagian meta wajib ada. UI tidak boleh menebak rentang tanggal, timezone, permission, atau apakah data sudah stale. Untuk route dan component di App Router, gunakan dokumentasi resmi Next.js App Router. Untuk component, state, dan conditional rendering, rujuk React Learn.

SQL aggregation dengan batas yang eksplisit

Contoh PostgreSQL ini mengasumsikan tabel invoices memiliki tenant_id, status, amount_cents, paid_at, dan currency. Tanggal akhir dibuat eksklusif agar bug akhir bulan, milidetik, dan timezone lebih mudah dihindari.

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;

Pitfall yang sering terjadi sangat konkret: memakai 23:59:59 sebagai akhir periode, menghitung rata-rata dari persentase harian, lupa filter tenant dalam query chart, atau mencampur refund sebagai paid revenue. Minta Claude Code meninjau hal ini sebagai risiko kebenaran bisnis, bukan sekadar gaya kode.

TypeScript types untuk menjaga contract

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 dengan loading, error, empty, dan stale state

Client component ini menangani loading, 403, error API, data kosong, stale data, KPI cards, dan chart dengan fallback table. Untuk aksesibilitas, gunakan MDN Web Accessibility dan Recharts accessibility wiki.

"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("id-ID");
}

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: "Anda tidak memiliki izin untuk melihat dashboard ini." });
          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">Memuat dashboard...</section>;
  if (state.status === "empty") return <section className="p-6">Tidak ada KPI untuk filter ini.</section>;
  if (state.status === "error") {
    return (
      <section role="alert" className="p-6">
        <h2 className="text-lg font-semibold">Dashboard gagal dimuat</h2>
        <p>{state.message}</p>
        <button className="mt-4 rounded bg-slate-900 px-4 py-2 text-white" onClick={() => location.reload()}>
          Coba lagi
        </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} sampai {data.meta.dateRange.to}, timezone {data.meta.timezone}.
          Dibuat pada {data.meta.generatedAt}.
        </p>
        {stale && (
          <p role="status" className="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
            Data ini sudah stale. Jalankan agregasi ulang sebelum mengambil keputusan.
          </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>
  );
}

Jangan menyampaikan makna hanya lewat warna. Kenaikan dan penurunan perlu label, angka, periode pembanding, dan tabel. Jika UI bisa filter berdasarkan plan, region, owner, atau tanggal, URL, API, dan SQL harus memakai parameter yang sama.

Prompt review untuk Claude Code

Ikuti pola Claude Code common workflows: investigasi, edit, test, dan review dalam siklus kecil.

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.

Untuk materi terkait, baca visualisasi data dengan Claude Code dan panduan RBAC. Jika tim Anda ingin menjadikan alur ini sebagai standar, ClaudeCodeLab menyediakan template Claude Code serta training dan consultation untuk KPI dictionary, prompt review, dan aturan repository.

Setelah mencoba alur ini dengan data SaaS contoh ClaudeCodeLab, manfaat terbesarnya adalah tidak memulai dari layar. Begitu contract memuat timezone, waktu generate, status stale, dan permission, review Claude Code menjadi jauh lebih spesifik. Dashboard lebih cepat selesai ketika syarat kepercayaannya dikunci sebelum polish visual.

#Claude Code #dashboard SaaS #React #Next.js #visualisasi data #Recharts
Gratis

PDF gratis: cheatsheet Claude Code

Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.

Kami menjaga datamu dan tidak mengirim spam.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.