freeDashboard · Next.js + Tailwind
Tallybook
A dashboard for running subscription billing: recurring revenue and movement charts, a filterable invoice table with refunds behind a confirmation, a retry queue for failed payments with a dunning schedule, and plan management.
loopkitui.com/t/billing-adminscroll inside ↓
Built from
- Dash kit (shell, charts, tables)in the zip
For: Finance and ops teams at subscription software companies.
prompt
You are building a complete dashboard app screen with Loopkit: the "Tallybook" template.
Goal: A dashboard for running subscription billing: recurring revenue and movement charts, a filterable invoice table with refunds behind a confirmation, a retry queue for failed payments with a dunning schedule, and plan management.
Audience: Finance and ops teams at subscription software companies.
Stack: Next.js App Router, TypeScript, Tailwind CSS v4, framer-motion. Colour tokens are shadcn's plus Loopkit's --signal, --ok, --warn, --info (see https://loopkitui.com/docs/theming). Light theme by default, dark via the .dark class.
1. Install the Loopkit components this page uses:
npx shadcn@latest add https://loopkitui.com/r/dash-kit.json # dash-kit
2. Create app/page.tsx with the source below. Keep the section order and the copy structure; replace the product name, copy and links with mine.
3. Check it at 360, 768, 1440 and 2560 px wide, in light and dark, with reduced motion on and off. The console must be clean.
Page source:
```tsx
"use client";
import * as React from "react";
import {
DashShell,
Panel,
Kpi,
AreaChart,
BarChart,
DataTable,
Donut,
Pill,
Seg,
Btn,
Sheet,
Facts,
Confirm,
Dropdown,
Progress,
Toggle,
useToast,
series,
fmt,
type Column,
type Tone,
} from "@/components/loopkit/dash-kit";
/**
* Tallybook — billing admin for a subscription business: revenue, invoices, failed payments
* with a dunning schedule, plans and refunds that always ask before money moves.
*/
type InvState = "paid" | "open" | "failed" | "refunded";
interface Invoice {
id: string;
customer: string;
plan: string;
amount: number;
state: InvState;
issued: string;
method: string;
}
const INVOICES: Invoice[] = [
{ id: "INV-20931", customer: "Nordvik Studio", plan: "Team", amount: 480, state: "paid", issued: "Nov 14", method: "Card ·4242" },
{ id: "INV-20930", customer: "Harbor & Pine", plan: "Business", amount: 1290, state: "open", issued: "Nov 14", method: "Bank transfer" },
{ id: "INV-20929", customer: "Lumen Clinics", plan: "Business", amount: 1290, state: "failed", issued: "Nov 13", method: "Card ·0119" },
{ id: "INV-20928", customer: "Quarry Labs", plan: "Starter", amount: 49, state: "paid", issued: "Nov 13", method: "Card ·7781" },
{ id: "INV-20927", customer: "Fieldnote Co", plan: "Team", amount: 480, state: "refunded", issued: "Nov 12", method: "Card ·5531" },
{ id: "INV-20926", customer: "Oakline Freight", plan: "Enterprise", amount: 4800, state: "paid", issued: "Nov 12", method: "Invoice" },
{ id: "INV-20925", customer: "Brightwell", plan: "Team", amount: 480, state: "failed", issued: "Nov 11", method: "Card ·9003" },
{ id: "INV-20924", customer: "Copperleaf", plan: "Starter", amount: 49, state: "paid", issued: "Nov 11", method: "Card ·6610" },
{ id: "INV-20923", customer: "Saltmarsh Media", plan: "Business", amount: 1290, state: "paid", issued: "Nov 10", method: "Card ·2208" },
];
const INV_TONE: Record<InvState, Tone> = { paid: "ok", open: "info", failed: "danger", refunded: "muted" };
const MONTHS = ["Dec", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov"];
const MRR = series(21, 12, 82000, 5000, 2600);
const DUNNING = [
{ id: "d1", customer: "Lumen Clinics", amount: 1290, attempt: 2, next: "Tomorrow 09:00", reason: "Card declined — insufficient funds" },
{ id: "d2", customer: "Brightwell", amount: 480, attempt: 1, next: "Nov 16 09:00", reason: "Card expired" },
{ id: "d3", customer: "Pebble Robotics", amount: 49, attempt: 3, next: "Final attempt Nov 17", reason: "Issuer asked for authentication" },
{ id: "d4", customer: "Tern Analytics", amount: 1290, attempt: 1, next: "Nov 18 09:00", reason: "Processing error" },
];
export default function Page() {
const [view, setView] = React.useState("overview");
const titles: Record<string, [string, string]> = {
overview: ["Revenue", "Recurring revenue, churn and collection health"],
invoices: ["Invoices", "Every invoice, with refunds and resends"],
dunning: ["Failed payments", "Retries and reminders for payments that didn't go through"],
plans: ["Plans", "Prices and what each plan includes"],
};
return (
<DashShell
brand="Tallybook"
mark="T"
workspace="Billing"
accent="ok"
variant="panel"
nav={[
{ id: "overview", label: "Revenue", icon: "chart" },
{ id: "invoices", label: "Invoices", icon: "file" },
{ id: "dunning", label: "Failed payments", icon: "alert", badge: DUNNING.length },
{ id: "plans", label: "Plans", icon: "tag" },
]}
active={view}
onNavigate={setView}
title={titles[view][0]}
subtitle={titles[view][1]}
searchPlaceholder="Search customers, invoices"
user={{ name: "Priya Raman", role: "Finance" }}
>
{view === "overview" && <Overview />}
{view === "invoices" && <Invoices />}
{view === "dunning" && <Dunning />}
{view === "plans" && <Plans />}
</DashShell>
);
}
function Overview() {
const [range, setRange] = React.useState<"6m" | "12m">("12m");
const n = range === "6m" ? 6 : 12;
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
<Kpi label="MRR" value={fmt.usd(MRR[11])} delta="+3.4%" hint="this month" spark={MRR} tone="ok" />
<Kpi label="Net revenue retention" value="108%" delta="+2 pt" spark={series(4, 12, 104, 2, 0.3)} tone="info" />
<Kpi label="Churned MRR" value="$2,140" delta="-12%" hint="vs Oct" spark={series(6, 12, 2600, 400, -30)} tone="warn" />
<Kpi label="Failed payments" value={fmt.usd(3109)} delta="4 accounts" good={false} tone="danger" />
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<Panel title="Monthly recurring revenue" subtitle="Including expansion, net of churn" action={<Seg size="sm" label="Range" options={["6m", "12m"] as const} value={range} onChange={setRange} />}>
<AreaChart labels={MONTHS.slice(-n)} series={[{ name: "MRR", data: MRR.slice(-n), tone: "ok" }]} format={(v) => `$${fmt.num(Math.round(v))}`} height={240} />
</Panel>
<Panel title="Revenue by plan" subtitle="Share of current MRR">
<Donut
center={`$${fmt.num(Math.round(MRR[11]))}`}
sub="MRR"
format={(v) => fmt.usd(v)}
parts={[
{ label: "Enterprise", value: 38400, tone: "accent" },
{ label: "Business", value: 29700, tone: "info" },
{ label: "Team", value: 18200, tone: "warn" },
{ label: "Starter", value: 6100, tone: "muted" },
]}
/>
</Panel>
</div>
<Panel title="Movement" subtitle="New, expansion and churned MRR by month">
<BarChart
labels={MONTHS.slice(-6)}
format={(v) => `$${fmt.num(Math.round(v))}`}
data={[
{ name: "New", data: series(31, 6, 4200, 900), tone: "ok" },
{ name: "Expansion", data: series(32, 6, 2100, 500), tone: "info" },
{ name: "Churned", data: series(33, 6, 1800, 400), tone: "danger" },
]}
/>
</Panel>
</div>
);
}
function Invoices() {
const toast = useToast();
const [rows, setRows] = React.useState(INVOICES);
const [state, setState] = React.useState<"all" | InvState>("all");
const [plan, setPlan] = React.useState("all");
const [open, setOpen] = React.useState<Invoice | null>(null);
const [ask, setAsk] = React.useState(false);
const shown = rows.filter((r) => (state === "all" || r.state === state) && (plan === "all" || r.plan === plan));
const cols: Column<Invoice>[] = [
{ key: "id", label: "Invoice", render: (r) => <span className="font-mono text-xs">{r.id}</span> },
{ key: "customer", label: "Customer", sort: (r) => r.customer },
{ key: "plan", label: "Plan", sort: (r) => r.plan },
{ key: "state", label: "Status", sort: (r) => r.state, render: (r) => <Pill tone={INV_TONE[r.state]} dot>{r.state}</Pill> },
{ key: "issued", label: "Issued" },
{ key: "amount", label: "Amount", align: "right", sort: (r) => r.amount, render: (r) => fmt.usd(r.amount) },
];
return (
<Panel
title="Invoices"
subtitle="Click an invoice for details"
pad={false}
action={
<div className="flex flex-wrap gap-2">
<Seg size="sm" label="Status" options={[{ value: "all", label: "All" }, { value: "paid", label: "Paid" }, { value: "open", label: "Open" }, { value: "failed", label: "Failed" }, { value: "refunded", label: "Refunded" }]} value={state} onChange={setState} />
<Dropdown className="w-36" label="Plan" value={plan} onChange={setPlan} options={[{ value: "all", label: "All plans" }, ...["Starter", "Team", "Business", "Enterprise"].map((p) => ({ value: p, label: p }))]} />
</div>
}
>
<DataTable columns={cols} rows={shown} onRowClick={setOpen} selected={open?.id} empty="No invoices match these filters." />
<Sheet
open={!!open}
onClose={() => setOpen(null)}
title={open ? `${open.id} · ${open.customer}` : ""}
footer={
open && (
<>
<Btn icon="download" onClick={() => toast(`${open.id} PDF downloaded`, "info")}>PDF</Btn>
{open.state !== "paid" && open.state !== "refunded" && (
<Btn icon="msg" onClick={() => toast(`Reminder sent to ${open.customer}`, "ok")}>Send reminder</Btn>
)}
{open.state === "paid" && (
<Btn variant="danger" onClick={() => setAsk(true)}>Refund</Btn>
)}
</>
)
}
>
{open && (
<div className="space-y-5">
<Facts
items={[
["Status", <Pill key="s" tone={INV_TONE[open.state]} dot>{open.state}</Pill>],
["Plan", open.plan],
["Issued", open.issued],
["Payment method", open.method],
["Subtotal", fmt.usd(open.amount * 0.8, 2)],
["VAT 25%", fmt.usd(open.amount * 0.2, 2)],
["Total", fmt.usd(open.amount, 2)],
]}
/>
<p className="text-xs text-muted-foreground">Refunds return the full amount to the original payment method and close the invoice.</p>
</div>
)}
</Sheet>
<Confirm
open={ask}
danger
title={open ? `Refund ${fmt.usd(open.amount)} to ${open.customer}?` : "Refund"}
body="The money goes back to the original payment method. This can't be undone from here."
confirmLabel="Refund"
onCancel={() => setAsk(false)}
onConfirm={() => {
if (open) {
setRows((rs) => rs.map((r) => (r.id === open.id ? { ...r, state: "refunded" } : r)));
toast(`${open.id} refunded`, "warn");
}
setAsk(false);
setOpen(null);
}}
/>
</Panel>
);
}
function Dunning() {
const toast = useToast();
const [items, setItems] = React.useState(DUNNING);
const [auto, setAuto] = React.useState(true);
const [cancel, setCancel] = React.useState<string | null>(null);
const target = items.find((i) => i.id === cancel);
return (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<Panel title="Retry queue" subtitle="Payments we'll try again automatically" pad={false}>
{items.length === 0 ? (
<p className="px-5 py-12 text-center text-sm text-muted-foreground">Every failed payment is resolved.</p>
) : (
<ul className="divide-y divide-border">
{items.map((d) => (
<li key={d.id} className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate font-medium">{d.customer}</p>
<Pill tone={d.attempt >= 3 ? "danger" : "warn"}>attempt {d.attempt} of 3</Pill>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">{d.reason} · next: {d.next}</p>
</div>
<span className="font-mono text-sm tabular-nums">{fmt.usd(d.amount)}</span>
<div className="flex gap-2">
<Btn
icon="refresh"
onClick={() => {
setItems((s) => s.filter((x) => x.id !== d.id));
toast(`Payment from ${d.customer} collected`, "ok");
}}
>
Retry now
</Btn>
<Btn variant="plain" onClick={() => setCancel(d.id)}>
Cancel plan
</Btn>
</div>
</li>
))}
</ul>
)}
</Panel>
<div className="space-y-6">
<Panel title="Recovery this month">
<p className="font-display text-3xl font-bold">71%</p>
<p className="text-sm text-muted-foreground">of failed payments recovered within 7 days</p>
<Progress className="mt-4 h-2" value={71} tone="ok" />
</Panel>
<Panel title="Dunning schedule" subtitle="What happens after a payment fails">
<ol className="space-y-3 text-sm">
{["Day 0 — retry and email the billing contact", "Day 3 — retry and send an in-app banner", "Day 7 — final retry and email the account owner", "Day 10 — pause the workspace, keep the data"].map((s, i) => (
<li key={s} className="flex gap-3">
<span className="grid size-6 shrink-0 place-items-center rounded-full bg-muted font-mono text-[11px]">{i + 1}</span>
<span className="min-w-0">{s}</span>
</li>
))}
</ol>
<div className="mt-5 flex items-center justify-between gap-3 border-t border-border pt-4 text-sm">
<span>Smart retries at the customer's usual pay time</span>
<Toggle label="Smart retries" checked={auto} onChange={(v) => { setAuto(v); toast(v ? "Smart retries on" : "Smart retries off", v ? "ok" : "warn"); }} />
</div>
</Panel>
</div>
<Confirm
open={!!target}
danger
title={target ? `Cancel ${target.customer}'s plan?` : ""}
body="The subscription ends now and no more retries are made. Their data stays for 30 days."
confirmLabel="Cancel plan"
onCancel={() => setCancel(null)}
onConfirm={() => {
setItems((s) => s.filter((x) => x.id !== cancel));
toast(`${target?.customer} cancelled`, "warn");
setCancel(null);
}}
/>
</div>
);
}
const PLANS = [
{ id: "starter", name: "Starter", price: 49, seats: 3, subs: 124, features: ["3 seats", "Email support", "Monthly invoices"] },
{ id: "team", name: "Team", price: 480, seats: 15, subs: 38, features: ["15 seats", "Priority support", "Usage reports"] },
{ id: "business", name: "Business", price: 1290, seats: 50, subs: 23, features: ["50 seats", "SSO", "Custom terms"] },
{ id: "enterprise", name: "Enterprise", price: 4800, seats: 500, subs: 8, features: ["Unlimited seats", "Dedicated manager", "Annual invoicing"] },
];
function Plans() {
const toast = useToast();
const [cycle, setCycle] = React.useState<"monthly" | "yearly">("monthly");
const [visible, setVisible] = React.useState<Record<string, boolean>>({ starter: true, team: true, business: true, enterprise: false });
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<Seg label="Billing cycle" options={[{ value: "monthly", label: "Monthly" }, { value: "yearly", label: "Yearly (−15%)" }]} value={cycle} onChange={setCycle} />
<Btn variant="primary" icon="plus" onClick={() => toast("New plan created as hidden", "info")}>New plan</Btn>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{PLANS.map((p) => (
<Panel key={p.id} title={p.name} action={<Pill tone={visible[p.id] ? "ok" : "muted"}>{visible[p.id] ? "On pricing page" : "Hidden"}</Pill>}>
<p className="font-display text-3xl font-bold tabular-nums">
{fmt.usd(cycle === "monthly" ? p.price : Math.round(p.price * 12 * 0.85))}
<span className="ml-1 text-sm font-normal text-muted-foreground">/{cycle === "monthly" ? "mo" : "yr"}</span>
</p>
<p className="mt-1 text-xs text-muted-foreground">{p.subs} active subscriptions</p>
<ul className="mt-4 space-y-1.5 text-sm">
{p.features.map((f) => (
<li key={f} className="flex items-center gap-2">
<span className="size-1.5 rounded-full bg-[var(--acc)]" />
{f}
</li>
))}
</ul>
<div className="mt-5 flex items-center justify-between border-t border-border pt-4 text-sm">
<span className="text-muted-foreground">Show on pricing page</span>
<Toggle
label={`Show ${p.name} on pricing page`}
checked={visible[p.id]}
onChange={(v) => {
setVisible((s) => ({ ...s, [p.id]: v }));
toast(`${p.name} ${v ? "shown" : "hidden"} on the pricing page`, "ok");
}}
/>
</div>
</Panel>
))}
</div>
</div>
);
}
```