/billing/usageMetered usage
Metered usage for the term a subscription is in: seats, requests and tokens against what the tier includes, an overage forecast against the ceiling, and where the organisation's usage goes workspace by workspace.
The page is a server component inside AppShell, with nav.ts describing the navigation as plain data. The billing period is the month ending on this workspace's db.subscriptions renewal, and metered usage is db.aiRequests counted inside it — real rows rather than a generated series. Those rows carry no workspace, so a workspace's share of them is a rule over the rows that do exist: every account still running takes usage in proportion to the seats it has filled, which sums back to the organisation's real total. The allowance is the other side of that rule — a tier includes so much per seat it covers, per day — and REFERENCE_DATE decides how much of the period has run and therefore where the pace lands. The forecast chart draws what has been used, the pace it is on and the ceiling as three lines, and only one of the first two is drawn on either side of today. Seats are counted across the organisation, in the same words /people/directory uses, so the two pages cannot disagree about how many are filled. actions.ts holds the mutation, a 'use server' file whose every export is an async function: raiseLimits is a server action that moves the subscription to the cheapest tier with room for those seats, or to the largest tier there is when none of them has room, so there is nothing to choose; nothing it writes is cached, because the repository replaces a row rather than editing it and a module-scope reference would keep naming the tier just left behind. It appears only when the meters say the tier has been outgrown, and a success replaces the offer with a confirmation whose heading takes focus and says what actually happened. Composes AppShell, PageHeader, FormSection, UsageMeter, AsyncButton, Widget, LineChart, SimpleTable, StatusBadge, NumberCell and PercentCell.
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { raiseLimits, signOut } from "./actions"
import { OverageForecast } from "./components/overage-forecast"
import { UsageMeters } from "./components/usage-meters"
import { WorkspaceUsage } from "./components/workspace-usage"
import {
currentUser,
exceedsOn,
forecastPoints,
meters,
period,
planName,
planSummary,
seatsSummary,
shellNotifications,
workspaceUsage,
} from "./data"
import { NAV, ROUTE } from "./nav"
// The period bounds are instants; UTC keeps the window the meters were counted
// over the same window the reader is told about, whatever zone they are in.
const DAY = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })
/**
* Metered usage for the term the subscription is in. The page is a server
* component inside the shell: it counts the period's rows through `db`, works
* out where the pace lands, and hands the one island that changes anything the
* server action that raises the tier.
*/
export default function BillingUsagePage() {
const usage = meters()
const term = period()
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Usage"
description="What the organisation has taken against what its plan includes, and where the month is heading."
meta={`${seatsSummary()} · ${planSummary()} · ${DAY.format(term.start)} – ${DAY.format(term.end)} UTC`}
/>
<UsageMeters
meters={usage}
planName={planName()}
organisation={seatsSummary()}
periodEnd={term.end}
onRaise={raiseLimits}
/>
<OverageForecast
points={forecastPoints()}
limit={usage.requests.limit ?? 0}
forecast={usage.requests.forecast}
exceedsOn={exceedsOn()}
/>
<WorkspaceUsage rows={workspaceUsage()} />
</AppShell>
)
}Install
npx shadcn@latest add @vibra/billing-usageNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { raiseLimits, signOut } from "./actions"
import { OverageForecast } from "./components/overage-forecast"
import { UsageMeters } from "./components/usage-meters"
import { WorkspaceUsage } from "./components/workspace-usage"
import {
currentUser,
exceedsOn,
forecastPoints,
meters,
period,
planName,
planSummary,
seatsSummary,
shellNotifications,
workspaceUsage,
} from "./data"
import { NAV, ROUTE } from "./nav"
// The period bounds are instants; UTC keeps the window the meters were counted
// over the same window the reader is told about, whatever zone they are in.
const DAY = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })
/**
* Metered usage for the term the subscription is in. The page is a server
* component inside the shell: it counts the period's rows through `db`, works
* out where the pace lands, and hands the one island that changes anything the
* server action that raises the tier.
*/
export default function BillingUsagePage() {
const usage = meters()
const term = period()
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Usage"
description="What the organisation has taken against what its plan includes, and where the month is heading."
meta={`${seatsSummary()} · ${planSummary()} · ${DAY.format(term.start)} – ${DAY.format(term.end)} UTC`}
/>
<UsageMeters
meters={usage}
planName={planName()}
organisation={seatsSummary()}
periodEnd={term.end}
onRaise={raiseLimits}
/>
<OverageForecast
points={forecastPoints()}
limit={usage.requests.limit ?? 0}
forecast={usage.requests.forecast}
exceedsOn={exceedsOn()}
/>
<WorkspaceUsage rows={workspaceUsage()} />
</AppShell>
)
}import { type NavConfig } from "@/lib/nav-config"
/** The route this page is installed at. AppShell matches the nav against it. */
export const ROUTE = "/billing/usage"
/**
* This product's navigation, as plain data. AppShell resolves the icon names,
* and the page hands it `ROUTE` as `activeHref` — so which item is current is
* settled by the route the page is installed at, not by the URL it happens to
* be framed under. Nothing here is a component, and nothing here says "I am the
* page you are on".
*
* Billing and Team are disclosures rather than links: an item with `items` is
* a button, so the leaf below it is what carries `aria-current`. `/billing` is
* not a page of its own, so it has no leaf for itself; `/people/directory` is, so it does.
*/
export const NAV: NavConfig = {
brand: { name: "Northwind", initial: "N", href: "/saas", caption: "Production" },
groups: [
{
label: "Workspace",
items: [
{ title: "Overview", href: "/saas", icon: "layout-dashboard" },
{ title: "Analytics", href: "/analytics", icon: "chart-line" },
{ title: "Reports", href: "/reports", icon: "file-text", badge: 4 },
],
},
{
label: "Account",
items: [
{
title: "Billing",
href: "/billing",
icon: "credit-card",
items: [
{ title: "Plans", href: "/billing/plans" },
{ title: "Invoices", href: "/billing/invoices" },
{ title: "Usage", href: "/billing/usage" },
],
},
{
title: "Team",
href: "/people/directory",
icon: "users",
items: [
{ title: "Members", href: "/people/directory" },
{ title: "Roles", href: "/people/roles" },
{ title: "Invitations", href: "/people/invitations" },
],
},
],
},
],
// Pinned under the groups, the way the secondary links were.
footer: [
{ title: "Settings", href: "/settings", icon: "settings" },
{ title: "Support", href: "/support", icon: "life-buoy" },
],
}/**
* What this page reads, and the one thing it changes.
*
* The organisation is Northwind, and `db.customers` holds one account row per
* workspace it runs — the rows sharing the Northwind name are its own, and the
* ones it has not closed are the ones it still runs. The workspace on show is
* Northwind Analytics; its `db.subscriptions` row fixes the term (the month
* ending on `renewsAt`) and the tier every meter is read against. Seats are
* counted across the whole organisation, in the same words `/people/directory` uses, so the
* two pages cannot disagree about how many are filled.
*
* Metered usage is `db.aiRequests` inside that term — real rows, not a
* generated series. They carry no workspace, so a workspace's share of them is
* a rule over the rows that do exist: every workspace still running takes usage
* in proportion to the seats it has filled, which sums back to the real total.
* The allowance is the same rule from the other side — a tier includes so much
* per seat it covers, per day — and `REFERENCE_DATE` decides how much of the
* term has run and therefore where the pace lands.
*
* Nothing `raiseLimits` writes is cached: the subscription is resolved on every
* call, because the repository replaces a row rather than editing it and a
* reference held at module scope would keep naming the tier just left behind.
*/
import { getInitials } from "@/lib/format"
import {
db,
REFERENCE_DATE,
type AiRequest,
type Member,
type Plan,
type Subscription,
} from "@/lib/sample-data"
const DAY_MS = 86_400_000
// What a tier includes for each seat it covers, per day. The price list carries
// seats and features; this is the metering the block adds on top of it, and it
// is the only rate written here.
const REQ_PER_SEAT_DAY = 1
const TOKENS_PER_SEAT_DAY = 4_000
// Every read is per call, never held at module scope: a workspace opened or
// closed, or a plan raised, since the server started is what the next render
// shows.
const workspaces = () => db.customers.all().filter((customer) => customer.company.startsWith("Northwind "))
function accountRow() {
const rows = workspaces()
return rows.find((customer) => customer.company === "Northwind Analytics") ?? rows[0]
}
/** The workspaces the organisation still runs: a closed account fills no seats. */
const liveWorkspaces = () => workspaces().filter((customer) => customer.status !== "churned")
const seatsOf = (rows: { seats: number }[]) => rows.reduce((total, customer) => total + customer.seats, 0)
/** The organisation, as both `/billing` and `/people/directory` state it, counted per call. */
export function organisation(): { name: string; workspaces: number; seatsFilled: number } {
const live = liveWorkspaces()
return {
name: "Northwind",
workspaces: live.length,
seatsFilled: live.reduce((total, customer) => total + customer.seats, 0),
}
}
/**
* The seats sentence, written once. `/billing` and `/people/directory` both render it, so
* neither can drift from the other about how many seats are filled.
*/
export function seatsSummary(): string {
const { seatsFilled, workspaces } = organisation()
return `${seatsFilled} seats filled across the organisation's ${workspaces} workspaces`
}
/** The other half of it: what the tier the organisation is on includes. */
export function planSummary(): string {
const plan = planRow()
return `${plan.seatsIncluded} included in ${plan.name}`
}
// Resolved per call, never held: `update` swaps the row for a new object, so a
// module-scope reference would keep naming the tier the workspace has left.
/** The billing relationship behind this workspace, resolved per call. */
export function subscriptionRow(): Subscription {
return (
db.subscriptions.all().find((row) => row.customerId === accountRow().id) ?? db.subscriptions.all()[0]
)
}
function planRow(): Plan {
const { planId } = subscriptionRow()
return db.plans.all().find((plan) => plan.id === planId) ?? db.plans.all()[0]
}
export type Period = { start: Date; end: Date; days: number; elapsed: number }
/** The term the meters cover: the month ending on the subscription's renewal. */
export function period(): Period {
const end = subscriptionRow().renewsAt
const start = new Date(
Date.UTC(
end.getUTCFullYear(),
end.getUTCMonth() - 1,
end.getUTCDate(),
end.getUTCHours(),
end.getUTCMinutes()
)
)
const days = Math.round((end.getTime() - start.getTime()) / DAY_MS)
// Elapsed is fractional, because a part-day of usage still counts.
return { start, end, days, elapsed: (REFERENCE_DATE.getTime() - start.getTime()) / DAY_MS }
}
/** The organisation's metered rows inside a term. */
function requestsIn(term: Period): AiRequest[] {
const from = term.start.getTime()
return db.aiRequests.all().filter((row) => {
const at = row.at.getTime()
return at >= from && at <= REFERENCE_DATE.getTime()
})
}
/** This workspace's share of the organisation's usage: the seats it has filled. */
const shareOfUsage = () => accountRow().seats / seatsOf(liveWorkspaces())
export type Meter = { used: number; limit: number | null; forecast: number }
function allowance(perSeatPerDay: number, term: Period): number {
return Math.round(planRow().seatsIncluded * perSeatPerDay * term.days)
}
/** Seats, requests and tokens — what is used, what is included, where it lands. */
export function meters(): { seats: Meter; requests: Meter; tokens: Meter } {
const term = period()
const rows = requestsIn(term)
const project = (used: number) => Math.round((used / term.elapsed) * term.days)
const share = shareOfUsage()
const { seatsFilled } = organisation()
const requests = Math.round(rows.length * share)
const tokens = Math.round(
rows.reduce((total, row) => total + row.promptTokens + row.completionTokens, 0) * share
)
return {
seats: {
// Organisation-wide, the same number `/people/directory` states: the tier is what the
// organisation is billed on, not what one workspace is.
used: seatsFilled,
limit: planRow().seatsIncluded,
// A seat is filled or it is not; there is no pace to run out.
forecast: seatsFilled,
},
requests: { used: requests, limit: allowance(REQ_PER_SEAT_DAY, term), forecast: project(requests) },
tokens: { used: tokens, limit: allowance(TOKENS_PER_SEAT_DAY, term), forecast: project(tokens) },
}
}
/** The tier the allowances above come from. */
export function planName(): string {
return planRow().name
}
export type ForecastPoint = {
date: string
used: number | null
projected: number | null
}
/**
* The period day by day: requests taken so far, the pace they are on, and the
* ceiling the tier includes. The projection is anchored to the running total on
* the last elapsed day, so the two lines meet there rather than crossing, and
* the series runs to the last day of the term.
*/
export function forecastPoints(): ForecastPoint[] {
const term = period()
const rows = requestsIn(term)
const daily = new Map<string, number>()
for (const row of rows) {
const key = row.at.toISOString().slice(0, 10)
daily.set(key, (daily.get(key) ?? 0) + 1)
}
const share = shareOfUsage()
const taken = Math.round(rows.length * share)
// The index of the last day with rows in it: where the two lines meet.
const junction = Math.ceil(term.elapsed) - 1
const remaining = Math.max(1, term.days - junction)
// The line ends exactly on the number the panel prints, so the chart and the
// copy under it cannot disagree by a rounding step.
const gain = Math.max(0, meters().requests.forecast - taken)
let running = 0
// days + 1 points, so the last one is the day the term ends.
return Array.from({ length: term.days + 1 }, (_, index) => {
const at = new Date(term.start.getTime() + index * DAY_MS)
const date = at.toISOString().slice(0, 10)
const elapsed = index <= junction
if (elapsed) running += Math.round((daily.get(date) ?? 0) * share)
return {
date,
used: elapsed ? running : null,
// Anchored to what has actually been taken, then carried on at the pace.
projected:
index >= junction ? taken + Math.round((gain * (index - junction)) / remaining) : null,
}
})
}
/** The day the pace runs through the allowance, or undefined if it never does. */
export function exceedsOn(): Date | undefined {
const term = period()
const { requests } = meters()
if (requests.limit === null || requests.forecast <= requests.limit) return undefined
const rate = requests.used / term.elapsed
return new Date(term.start.getTime() + (requests.limit / rate) * DAY_MS)
}
export type WorkspaceUsage = {
id: string
name: string
plan: string
status: string
seats: number
requests: number
share: number
}
/** Where the organisation's usage goes, largest workspace first. */
export function workspaceUsage(): WorkspaceUsage[] {
const rows = requestsIn(period())
const live = liveWorkspaces()
const seats = seatsOf(live)
return live
.map((customer) => {
const share = customer.seats / seats
return {
id: customer.id,
name: customer.company,
plan: customer.plan,
status: customer.status,
seats: customer.seats,
requests: Math.round(rows.length * share),
share,
}
})
.sort((a, b) => b.seats - a.seats)
}
/** What a raise landed on — the confirmation says whether it has room. */
export type RaisedTo = { planId: string; name: string; seatsIncluded: number }
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}"use server"
/**
* Everything this page changes. A `"use server"` file rather than a directive
* inside each function: an inline one is only legal in a module the bundler
* knows is server-only, and `data.ts` is imported for its types by components
* that are not. Every export here is an async function returning `Result`.
*/
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { db, type Result } from "@/lib/sample-data"
import { organisation, subscriptionRow, type RaisedTo } from "./data"
/**
* Move onto the cheapest tier with room for the seats the organisation has
* filled, or onto the largest tier there is when none of them has room — the
* page asks for this when the meters say the tier has been outgrown, so the
* destination is derived from the overrun rather than chosen.
*/
export async function raiseLimits(): Promise<Result<RaisedTo>> {
const byPrice = db.plans.all().sort((a, b) => a.priceMonthlyCents - b.priceMonthlyCents)
const { name, seatsFilled } = organisation()
const withRoom = byPrice.find((plan) => plan.seatsIncluded >= seatsFilled)
const roomy = withRoom ?? byPrice[byPrice.length - 1]
const subscription = subscriptionRow()
if (roomy.id === subscription.planId) {
return {
ok: false,
error: {
code: "already_on_plan",
// Two ways to land here: the tier with room is the current one, or
// there is no bigger tier left. Say which.
message: withRoom
? `${name} is already on ${roomy.name}, which covers ${seatsFilled} seats.`
: `${name} is already on ${roomy.name}, the largest tier there is.`,
},
}
}
const saved = await db.subscriptions.update(subscription.id, { planId: roomy.id })
if (!saved.ok) return saved
return {
ok: true,
data: { planId: roomy.id, name: roomy.name, seatsIncluded: roomy.seatsIncluded },
}
}
/** Signing out is the shell's one action, and a server action for the same reason. */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}"use client"
import { formatNumber } from "@/lib/format"
import type { ChartAnnotation } from "@/components/ui/chart-core"
import { LineChart } from "@/components/ui/line-chart"
import { Widget } from "@/components/ui/widget"
import type { ForecastPoint } from "../data"
// The axis wants a day, not a date: the year is the same on every tick and the
// panel already says which month the period is.
const DAY_LABEL = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
})
// The same UTC pinning as the axis above, for the date in the copy.
const DAY = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })
const whole = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })
/** Requests taken so far, the pace they are on, and the ceiling the tier includes. */
export function OverageForecast({
points,
limit,
forecast,
exceedsOn,
}: {
points: ForecastPoint[]
limit: number
forecast: number
exceedsOn?: Date
}) {
const overBy = forecast - limit
// The allowance is a threshold, not a measurement: a flat third line pretends
// to be data and takes a colour off the palette to do it. As a reference line
// it reads as the ceiling it is — and it is still printed in the chart's own
// visually hidden rows, so it is readable without seeing the dash.
const annotations: ChartAnnotation[] = [
{
kind: "line",
axis: "y",
value: limit,
label: "Included",
tone: exceedsOn ? "warning" : "neutral",
},
]
return (
<Widget
title="Overage forecast"
description={
exceedsOn
? `At this pace the allowance runs out on ${DAY.format(exceedsOn)}.`
: "At this pace the period closes inside the allowance."
}
footer={
exceedsOn
? `Included ${whole(limit)} · heading for ${whole(forecast)}, which is ${whole(overBy)} to bill as overage.`
: `Included ${whole(limit)} · heading for ${whole(forecast)}.`
}
>
<LineChart
data={points}
index="date"
height={240}
dots={false}
series={[
{ key: "used", label: "Used", color: "chart-1" },
{ key: "projected", label: "Projected", color: "chart-4" },
]}
annotations={annotations}
valueFormatter={whole}
indexFormatter={(value) => DAY_LABEL.format(new Date(`${value}T00:00:00Z`))}
/>
</Widget>
)
}"use client"
import * as React from "react"
import { AsyncButton } from "@/components/ui/async-button"
export type LimitsRaised =
| { ok: true; data: { planId: string; name: string; seatsIncluded: number } }
| { ok: false; error: { code: string; message: string; field?: string } }
/**
* The one thing this page changes. It appears only when the meters say the
* tier has been outgrown; the tier it moves to is worked out from the overrun,
* so there is nothing to choose. A failure is said out loud, and a success
* replaces the offer with a confirmation whose heading takes focus and says
* what actually happened — including that the meters above were rendered on the
* server and only read the new tier on the next load. Nothing navigates on its
* own.
*/
export function RaiseLimits({
seatsInUse,
seatsIncluded,
onRaise,
}: {
seatsInUse: number
seatsIncluded: number
onRaise: () => Promise<LimitsRaised>
}) {
const [done, setDone] = React.useState<{ name: string; seatsIncluded: number } | null>(null)
const [error, setError] = React.useState<string | null>(null)
const headingRef = React.useRef<HTMLHeadingElement>(null)
React.useEffect(() => {
if (done) headingRef.current?.focus()
}, [done])
const over = seatsInUse - seatsIncluded
if (over <= 0 && !done) return null
async function raise() {
setError(null)
const result = await onRaise()
if (!result.ok) {
setError(result.error.message)
return
}
setDone({ name: result.data.name, seatsIncluded: result.data.seatsIncluded })
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-3 panel ring-warning/40 p-4">
<div className="flex min-w-0 flex-col gap-0.5">
<h3 ref={headingRef} tabIndex={-1} className="text-sm font-medium outline-none">
{done ? `Now on ${done.name}` : "This tier has been outgrown"}
</h3>
<p className="text-sm text-muted-foreground">
{done
? done.seatsIncluded >= seatsInUse
? `All ${seatsInUse} filled seats now sit inside ${done.name}'s allowance of ${done.seatsIncluded}. Reload the page and every meter above is read against it.`
: `${done.name} is the largest tier there is, and ${seatsInUse - done.seatsIncluded} of the filled seats sit past its allowance of ${done.seatsIncluded}, billed as extras. Reload the page and every meter above is read against it.`
: `${over} seats are filled across the organisation beyond the ${seatsIncluded} the plan includes, and every one of them is billed as an extra.`}
</p>
</div>
{done ? null : <AsyncButton size="sm" onClick={raise}>Move to a bigger plan</AsyncButton>}
</div>
{error ? (
<p role="alert" className="text-sm text-danger">
{error}
</p>
) : null}
</div>
)
}import { formatCompact, formatNumber } from "@/lib/format"
import { FormSection } from "@/components/ui/form-section"
import { UsageMeter } from "@/components/ui/usage-meter"
import type { Meter } from "../data"
import { RaiseLimits, type LimitsRaised } from "./raise-limits"
// The period bounds and the date the allowance runs out are instants, and this
// subscription renews at 05:35 UTC: formatted in the reader's own zone they
// would read as the day before west of UTC. The page's meta line says the dates
// are UTC; this is what makes that true.
const DAY = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })
const SHORT_DAY = new Intl.DateTimeFormat("en-US", { dateStyle: "short", timeZone: "UTC" })
const whole = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })
/** What is used against what the tier includes, and where the pace lands. */
export function UsageMeters({
meters,
planName,
organisation,
periodEnd,
onRaise,
}: {
meters: { seats: Meter; requests: Meter; tokens: Meter }
planName: string
/** The seats sentence, written once in data.ts and shared with `/people/directory`. */
organisation: string
periodEnd: Date
onRaise: () => Promise<LimitsRaised>
}) {
const { seats, requests, tokens } = meters
const over = seats.used - (seats.limit ?? seats.used)
return (
<FormSection
title="This period"
description={`${organisation}, on ${planName}. The term ends ${DAY.format(periodEnd)}, and every allowance starts again then.`}
>
<div className="grid gap-4 md:grid-cols-3">
<UsageMeter
label="Seats filled"
used={seats.used}
limit={seats.limit}
unit="seats"
tone="auto"
resetsAt={
over > 0
? `${over} beyond what the tier includes, across the organisation.`
: `${-over} still free on this tier, across the organisation.`
}
/>
<UsageMeter
label="AI requests"
used={requests.used}
limit={requests.limit}
tone="auto"
format={whole}
resetsAt={`Heading for ${whole(requests.forecast)} by ${SHORT_DAY.format(periodEnd)}.`}
/>
<UsageMeter
label="Tokens"
used={tokens.used}
limit={tokens.limit}
tone="auto"
format={(value) => formatCompact(value)}
resetsAt={`Heading for ${formatCompact(tokens.forecast)} by ${SHORT_DAY.format(periodEnd)}.`}
/>
</div>
<RaiseLimits
seatsInUse={seats.used}
seatsIncluded={seats.limit ?? seats.used}
onRaise={onRaise}
/>
</FormSection>
)
}import { FormSection } from "@/components/ui/form-section"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { NumberCell, PercentCell } from "@/components/ui/table-cells"
import type { WorkspaceUsage as Row } from "../data"
// StatusBadge has no word for an account that is behind on payment.
const STATUS_MAP = { suspended: "warning", trial: "info" } as const
const COLUMNS: SimpleTableColumn<Row>[] = [
{ key: "name", header: "Workspace" },
{
key: "plan",
header: "Plan",
cell: (row) => <span className="text-muted-foreground capitalize">{row.plan}</span>,
},
{
key: "status",
header: "Status",
cell: (row) => <StatusBadge status={row.status} map={STATUS_MAP} />,
},
{ key: "seats", header: "Seats", align: "right", cell: (row) => <NumberCell value={row.seats} /> },
{
key: "requests",
header: "Requests",
align: "right",
cell: (row) => <NumberCell value={row.requests} />,
},
{
key: "share",
header: "Share",
align: "right",
width: "120px",
cell: (row) => <PercentCell value={row.share} showBar />,
},
]
/** Nothing on this panel holds state, so it stays a plain function component. */
export function WorkspaceUsage({ rows }: { rows: Row[] }) {
return (
<FormSection
title="Usage by workspace"
description="The organisation's metered rows carry no workspace of their own, so each one takes the share its filled seats account for."
>
<SimpleTable size="sm" columns={COLUMNS} rows={rows} rowKey="id" hoverable />
</FormSection>
)
}