Add an Admin menu with a Billing page of credit usage
Organization admins can see what credits were used, by date, user and module, filter by period, see the total and export it as CSV. Other members don't get the menu, and the engine refuses them the data anyway. It shows credits because the engine does not record LLM tokens. The ledger has no date filter, so a period is loaded page by page and cut to size here. It also can't tell Agent use from other features, so every charge is listed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
12
src/App.tsx
12
src/App.tsx
@@ -1,12 +1,21 @@
|
||||
import { useAuth } from '@clerk/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AuthGate } from './auth/AuthGate'
|
||||
import { AppLayout } from './components/AppLayout'
|
||||
import { AppLayout, ORG_ADMIN } from './components/AppLayout'
|
||||
import { BillingPage } from './pages/Billing'
|
||||
import { BrainPage } from './pages/Brain'
|
||||
import { ProductionPage } from './pages/Production'
|
||||
import { InstructionPage } from './pages/Instruction'
|
||||
import { SchedulePage } from './pages/Schedule'
|
||||
import { ResultsPage } from './pages/Results'
|
||||
|
||||
// For a member who opens the link directly; the engine refuses them the data either way.
|
||||
function AdminOnly({ children }: { children: ReactNode }) {
|
||||
const { orgRole } = useAuth()
|
||||
return orgRole === ORG_ADMIN ? children : <Navigate replace to="/production" />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthGate>
|
||||
@@ -17,6 +26,7 @@ export default function App() {
|
||||
<Route path="/instruction" element={<InstructionPage />} />
|
||||
<Route path="/schedule" element={<SchedulePage />} />
|
||||
<Route path="/results" element={<ResultsPage />} />
|
||||
<Route path="/admin/billing" element={<AdminOnly><BillingPage /></AdminOnly>} />
|
||||
</Route>
|
||||
{/* Paths from before the routes were renamed, so old links keep working. */}
|
||||
<Route path="/brain" element={<Navigate replace to="/instruction" />} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { useClerk, useOrganization, useOrganizationList, useUser } from '@clerk/react'
|
||||
import { useAuth, useClerk, useOrganization, useOrganizationList, useUser } from '@clerk/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { LANGS, locale, setLang, t, useLang, type Key } from '../i18n'
|
||||
@@ -11,9 +11,12 @@ import { ErrorBoundary } from './ErrorBoundary'
|
||||
// Learning Studio (learning-lab/frontend); both apps share one Clerk instance, so the user stays signed in.
|
||||
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
|
||||
|
||||
/** The Clerk role the engine treats as the organization's owner (`require_org_owner`). */
|
||||
export const ORG_ADMIN = 'org:admin'
|
||||
|
||||
// The Production dashboard shows what Express is making; the four settings doors run it:
|
||||
// feed the Brain, give an instruction, watch the schedule, collect results.
|
||||
export const NAV_GROUPS: { label: Key; items: { to: string; label: Key; icon: IconName }[] }[] = [
|
||||
export const NAV_GROUPS: { label: Key; adminOnly?: boolean; items: { to: string; label: Key; icon: IconName }[] }[] = [
|
||||
{ label: 'nav.dashboard', items: [{ to: '/production', label: 'nav.production', icon: 'chart' }] },
|
||||
{
|
||||
label: 'nav.settings',
|
||||
@@ -24,6 +27,7 @@ export const NAV_GROUPS: { label: Key; items: { to: string; label: Key; icon: Ic
|
||||
{ to: '/results', label: 'nav.results', icon: 'layers' },
|
||||
],
|
||||
},
|
||||
{ label: 'nav.admin', adminOnly: true, items: [{ to: '/admin/billing', label: 'nav.billing', icon: 'coin' }] },
|
||||
]
|
||||
export const NAV = NAV_GROUPS.flatMap(g => g.items)
|
||||
|
||||
@@ -150,6 +154,7 @@ export function AppLayout() {
|
||||
const [help, setHelp] = useState(false)
|
||||
const { user } = useUser()
|
||||
const { signOut } = useClerk()
|
||||
const { orgRole } = useAuth()
|
||||
const { pathname } = useLocation()
|
||||
const current = NAV.find(n => pathname.startsWith(n.to))
|
||||
// Nav groups stay closed unless they hold the current page; a click on the label overrides that until the next route.
|
||||
@@ -178,7 +183,7 @@ export function AppLayout() {
|
||||
<NavLink to="/production"><Brand /></NavLink>
|
||||
<OrgSwitcher />
|
||||
<nav aria-label={t('layout.mainNav')}>
|
||||
{NAV_GROUPS.map(g => {
|
||||
{NAV_GROUPS.filter(g => !g.adminOnly || orgRole === ORG_ADMIN).map(g => {
|
||||
// The icon rail has no group labels, so every item shows.
|
||||
const open = collapsed || (toggled[g.label] ?? g.items.some(n => pathname.startsWith(n.to)))
|
||||
return [
|
||||
|
||||
93
src/express/billing.test.ts
Normal file
93
src/express/billing.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { dayKey, entryTime, lastDays, ledgerSince, moduleOf, usageCsv, usageInPeriod, type LedgerEntry } from './billing'
|
||||
import { request } from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({ request: vi.fn() }))
|
||||
|
||||
const at = (y: number, m: number, d: number, h = 12) => new Date(y, m - 1, d, h).toISOString()
|
||||
const entry = (id: number, kind: string, amount: number, created_at: string): LedgerEntry =>
|
||||
({ id, kind, amount, activity: 'agent.browse.search', actor_name: 'Ana', created_at })
|
||||
|
||||
afterEach(() => vi.mocked(request).mockReset())
|
||||
|
||||
describe('entryTime', () => {
|
||||
it('reads a time without an offset as UTC', () => {
|
||||
expect(entryTime('2026-11-04T01:00:00').toISOString()).toBe('2026-11-04T01:00:00.000Z')
|
||||
expect(entryTime('2026-11-04T01:00:00.250').toISOString()).toBe('2026-11-04T01:00:00.250Z')
|
||||
})
|
||||
|
||||
it('keeps an offset that is there', () => {
|
||||
expect(entryTime('2026-11-04T08:00:00+07:00').toISOString()).toBe('2026-11-04T01:00:00.000Z')
|
||||
expect(entryTime('2026-11-04T01:00:00Z').toISOString()).toBe('2026-11-04T01:00:00.000Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('usageInPeriod', () => {
|
||||
it('includes both whole end days and nothing outside them', () => {
|
||||
const { rows } = usageInPeriod([
|
||||
entry(1, 'charge', -1, new Date(2026, 10, 1, 0, 0).toISOString()),
|
||||
entry(2, 'charge', -2, new Date(2026, 10, 3, 23, 59).toISOString()),
|
||||
entry(3, 'charge', -4, new Date(2026, 10, 4, 0, 0).toISOString()),
|
||||
entry(4, 'charge', -8, new Date(2026, 9, 31, 23, 59).toISOString()),
|
||||
], '2026-11-01', '2026-11-03')
|
||||
expect(rows.map(r => r.id)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('subtracts refunds and ignores grants and adjustments', () => {
|
||||
const { rows, total } = usageInPeriod([
|
||||
entry(1, 'charge', -10, at(2026, 11, 2)),
|
||||
entry(2, 'refund', 3, at(2026, 11, 2)),
|
||||
entry(3, 'grant', 500, at(2026, 11, 2)),
|
||||
entry(4, 'adjustment', -20, at(2026, 11, 2)),
|
||||
], '2026-11-01', '2026-11-03')
|
||||
expect(total).toBe(7)
|
||||
expect(rows.map(r => r.id)).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ledgerSince', () => {
|
||||
it('follows pages until the ledger ends', async () => {
|
||||
vi.mocked(request)
|
||||
.mockResolvedValueOnce({ entries: [entry(3, 'charge', -1, at(2026, 11, 3))], next_before_id: 3 })
|
||||
.mockResolvedValueOnce({ entries: [entry(2, 'charge', -1, at(2026, 11, 2))], next_before_id: null })
|
||||
expect((await ledgerSince('2026-11-01')).map(e => e.id)).toEqual([3, 2])
|
||||
expect(vi.mocked(request).mock.calls[1][0]).toContain('before_id=3')
|
||||
})
|
||||
|
||||
it('stops once a page reaches past the start of the period', async () => {
|
||||
vi.mocked(request).mockResolvedValueOnce({ entries: [entry(9, 'charge', -1, at(2026, 10, 1))], next_before_id: 9 })
|
||||
await ledgerSince('2026-11-01')
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usageCsv', () => {
|
||||
const when = new Date(2026, 10, 3, 9, 5).toISOString()
|
||||
|
||||
it('writes a header and one row per entry, with refunds negative', () => {
|
||||
const lines = usageCsv([entry(1, 'charge', -5, when), entry(2, 'refund', 3, when)]).split('\r\n')
|
||||
expect(lines[0]).toBe('Date,User,Module,Activity,Type,Credits')
|
||||
expect(lines[1]).toBe('2026-11-03 09:05,Ana,Agent,browse search,Charge,5')
|
||||
expect(lines[2]).toBe('2026-11-03 09:05,Ana,Agent,browse search,Refund,-3')
|
||||
})
|
||||
|
||||
it('quotes what would break a column and defuses formulas', () => {
|
||||
const row = (actor_name: string | null) => usageCsv([{ ...entry(1, 'charge', -5, when), actor_name }]).split('\r\n')[1].split(',Agent')[0]
|
||||
expect(row('Doe, "Ana"')).toBe('2026-11-03 09:05,"Doe, ""Ana"""')
|
||||
expect(row('=HYPERLINK("x")')).toBe(`2026-11-03 09:05,"'=HYPERLINK(""x"")"`)
|
||||
expect(row(null)).toBe('2026-11-03 09:05,-')
|
||||
})
|
||||
})
|
||||
|
||||
describe('helpers', () => {
|
||||
it('names the module and detail of an activity', () => {
|
||||
expect(moduleOf('agent.browse.search')).toEqual({ module: 'Agent', detail: 'browse search' })
|
||||
expect(moduleOf('slide_deck.generate')).toEqual({ module: 'Slide deck', detail: 'generate' })
|
||||
expect(moduleOf(null)).toEqual({ module: '-', detail: '' })
|
||||
})
|
||||
|
||||
it('counts the last days including today, in local dates', () => {
|
||||
expect(dayKey(new Date(2026, 0, 5))).toBe('2026-01-05')
|
||||
expect(lastDays(30, new Date(2026, 10, 4))).toEqual({ from: '2026-10-06', to: '2026-11-04' })
|
||||
})
|
||||
})
|
||||
93
src/express/billing.ts
Normal file
93
src/express/billing.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// Billing reads the organization's credit ledger. The engine records credits, not LLM tokens, and its
|
||||
// ledger has no date filter, so a period is loaded page by page and cut to size here.
|
||||
import { request } from '../api/client'
|
||||
import { t } from '../i18n'
|
||||
|
||||
export interface LedgerEntry {
|
||||
id: number
|
||||
kind: string
|
||||
/** Signed: negative for a charge, positive for a refund or grant. */
|
||||
amount: number
|
||||
activity: string | null
|
||||
actor_name: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface LedgerPage { entries: LedgerEntry[]; next_before_id: number | null }
|
||||
|
||||
const LEDGER = '/organizations/current/dashboard/ledger'
|
||||
|
||||
// The ledger's times are UTC without saying so; new Date() alone would read them as local time.
|
||||
export const entryTime = (iso: string) => new Date(/(Z|[+-]\d\d:?\d\d)$/.test(iso) ? iso : `${iso}Z`)
|
||||
|
||||
const startOf = (day: string) => new Date(`${day}T00:00:00`)
|
||||
const endOf = (day: string) => {
|
||||
const end = startOf(day)
|
||||
end.setDate(end.getDate() + 1)
|
||||
return end
|
||||
}
|
||||
|
||||
/** A local date as the yyyy-mm-dd a date input uses. */
|
||||
export function dayKey(date: Date) {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
export function lastDays(days: number, today = new Date()) {
|
||||
const from = new Date(today)
|
||||
from.setDate(from.getDate() - (days - 1))
|
||||
return { from: dayKey(from), to: dayKey(today) }
|
||||
}
|
||||
|
||||
/** Every ledger entry back to the start of a day. Pages come newest first, so older pages can stop early. */
|
||||
export async function ledgerSince(from: string): Promise<LedgerEntry[]> {
|
||||
const start = startOf(from)
|
||||
const entries: LedgerEntry[] = []
|
||||
let before: number | null = null
|
||||
do {
|
||||
const query: string = before === null ? '' : `&before_id=${before}`
|
||||
const page: LedgerPage = await request<LedgerPage>(`${LEDGER}?limit=200${query}`)
|
||||
entries.push(...page.entries)
|
||||
const oldest = page.entries.at(-1)
|
||||
before = oldest && entryTime(oldest.created_at) >= start ? page.next_before_id : null
|
||||
} while (before !== null)
|
||||
return entries
|
||||
}
|
||||
|
||||
/** What a period consumed: charges, less what was refunded. Top-ups and adjustments are not consumption. */
|
||||
export function usageInPeriod(entries: LedgerEntry[], from: string, to: string) {
|
||||
const start = startOf(from)
|
||||
const end = endOf(to)
|
||||
const rows = entries.filter(e => {
|
||||
if (e.kind !== 'charge' && e.kind !== 'refund') return false
|
||||
const at = entryTime(e.created_at)
|
||||
return at >= start && at < end
|
||||
})
|
||||
return { rows, total: rows.reduce((sum, e) => sum - e.amount, 0) }
|
||||
}
|
||||
|
||||
/** "agent.browse.search" → Agent, browse search. */
|
||||
export function moduleOf(activity: string | null) {
|
||||
if (!activity) return { module: '-', detail: '' }
|
||||
const [first, ...rest] = activity.split('.').map(part => part.replace(/_/g, ' '))
|
||||
return { module: first.charAt(0).toUpperCase() + first.slice(1), detail: rest.join(' ') }
|
||||
}
|
||||
|
||||
// A name is free text: quoted when it needs it, and kept from running as a formula when opened in Excel.
|
||||
function cell(value: string | number) {
|
||||
if (typeof value === 'number') return String(value)
|
||||
const safe = /^([=+@\t\r]|-.)/.test(value) ? `'${value}` : value
|
||||
return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe
|
||||
}
|
||||
|
||||
/** The rows as CSV, in local time and plain numbers. The BOM makes Excel read names as UTF-8. */
|
||||
export function usageCsv(rows: LedgerEntry[]) {
|
||||
const header = [t('billing.colDate'), t('billing.colUser'), t('billing.colModule'), t('billing.colActivity'), t('billing.colType'), t('billing.colCredits')]
|
||||
const lines = rows.map(e => {
|
||||
const at = entryTime(e.created_at)
|
||||
const time = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
|
||||
const { module, detail } = moduleOf(e.activity)
|
||||
return [`${dayKey(at)} ${time}`, e.actor_name || '-', module, detail, t(e.kind === 'refund' ? 'billing.refund' : 'billing.charge'), -e.amount]
|
||||
})
|
||||
return `${[header, ...lines].map(line => line.map(cell).join(',')).join('\r\n')}\r\n`
|
||||
}
|
||||
56
src/i18n.ts
56
src/i18n.ts
@@ -14,6 +14,34 @@ const en = {
|
||||
'nav.dashboard': 'Dashboard',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.production': 'Production',
|
||||
'nav.admin': 'Admin',
|
||||
'nav.billing': 'Billing',
|
||||
'billing.title': 'Billing',
|
||||
'billing.description': 'The credits your organization used, by date, person and module. Only organization admins can see this page.',
|
||||
'billing.total': 'Total used',
|
||||
'billing.period': '{from} to {to}',
|
||||
'billing.balance': 'Current balance',
|
||||
'billing.balanceSub': 'Organization credits',
|
||||
'billing.usage': 'Credit usage',
|
||||
'billing.from': 'Start date',
|
||||
'billing.to': 'End date',
|
||||
'billing.colDate': 'Date',
|
||||
'billing.colUser': 'User',
|
||||
'billing.colModule': 'Module',
|
||||
'billing.colCredits': 'Credits',
|
||||
'billing.refund': 'Refund',
|
||||
'billing.loading': 'Loading credit usage',
|
||||
'billing.loadFailed': 'Credit usage could not be loaded',
|
||||
'billing.empty': 'No credits used in this period',
|
||||
'billing.emptyCopy': 'Try a wider date range.',
|
||||
'billing.more': 'Show more',
|
||||
'billing.count': '{n} transactions',
|
||||
'billing.countOne': '{n} transaction',
|
||||
'billing.export': 'Export CSV',
|
||||
'billing.colActivity': 'Activity',
|
||||
'billing.colType': 'Type',
|
||||
'billing.charge': 'Charge',
|
||||
'billing.note': 'Usage is counted in credits, the unit your organization is charged in. Refunds are taken off the total.',
|
||||
'layout.learningSpace': 'Learning space',
|
||||
'layout.switchOrg': 'Switch organization',
|
||||
'layout.breadcrumb': 'Workspace',
|
||||
@@ -585,6 +613,34 @@ const id: Record<Key, string> = {
|
||||
'nav.dashboard': 'Dasbor',
|
||||
'nav.settings': 'Pengaturan',
|
||||
'nav.production': 'Produksi',
|
||||
'nav.admin': 'Admin',
|
||||
'nav.billing': 'Billing',
|
||||
'billing.title': 'Billing',
|
||||
'billing.description': 'Kredit yang dipakai organisasi Anda, per tanggal, pengguna, dan modul. Halaman ini hanya bisa dilihat admin organisasi.',
|
||||
'billing.total': 'Total terpakai',
|
||||
'billing.period': '{from} sampai {to}',
|
||||
'billing.balance': 'Saldo saat ini',
|
||||
'billing.balanceSub': 'Kredit organisasi',
|
||||
'billing.usage': 'Konsumsi kredit',
|
||||
'billing.from': 'Tanggal mulai',
|
||||
'billing.to': 'Tanggal akhir',
|
||||
'billing.colDate': 'Tanggal',
|
||||
'billing.colUser': 'Pengguna',
|
||||
'billing.colModule': 'Modul',
|
||||
'billing.colCredits': 'Kredit',
|
||||
'billing.refund': 'Dikembalikan',
|
||||
'billing.loading': 'Memuat konsumsi kredit',
|
||||
'billing.loadFailed': 'Konsumsi kredit tidak bisa dimuat',
|
||||
'billing.empty': 'Tidak ada kredit terpakai di periode ini',
|
||||
'billing.emptyCopy': 'Coba rentang tanggal yang lebih lebar.',
|
||||
'billing.more': 'Tampilkan lagi',
|
||||
'billing.count': '{n} transaksi',
|
||||
'billing.countOne': '{n} transaksi',
|
||||
'billing.export': 'Ekspor CSV',
|
||||
'billing.colActivity': 'Aktivitas',
|
||||
'billing.colType': 'Jenis',
|
||||
'billing.charge': 'Terpakai',
|
||||
'billing.note': 'Pemakaian dihitung dalam kredit, satuan yang ditagihkan ke organisasi Anda. Kredit yang dikembalikan mengurangi total.',
|
||||
'layout.learningSpace': 'Ruang belajar',
|
||||
'layout.switchOrg': 'Ganti organisasi',
|
||||
'layout.breadcrumb': 'Ruang kerja',
|
||||
|
||||
100
src/pages/Billing.tsx
Normal file
100
src/pages/Billing.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { locale, t, useLang } from '../i18n'
|
||||
import { workspaceCredits } from '../api/engine'
|
||||
import { entryTime, lastDays, ledgerSince, moduleOf, usageCsv, usageInPeriod } from '../express/billing'
|
||||
import { download } from '../express/results'
|
||||
import { Icon } from '../components/Icon'
|
||||
import { Empty, PageHead, Skeleton, SkeletonRows } from '../components/Page'
|
||||
|
||||
const PAGE = 100
|
||||
|
||||
// The engine limits the ledger to organization admins, whatever this page shows.
|
||||
export function BillingPage() {
|
||||
useLang()
|
||||
const [{ from, to }, setPeriod] = useState(() => lastDays(30))
|
||||
const [shown, setShown] = useState(PAGE)
|
||||
// A date input can be cleared, and a start after the end would be an empty period: keep the pair valid.
|
||||
const pick = (from: string, to: string) => { setPeriod({ from, to }); setShown(PAGE) }
|
||||
|
||||
const ledger = useQuery({ queryKey: ['billing', from], queryFn: () => ledgerSince(from) })
|
||||
const balance = useQuery({ queryKey: ['credits'], queryFn: workspaceCredits })
|
||||
const usage = ledger.data && usageInPeriod(ledger.data, from, to)
|
||||
|
||||
const number = (n: number) => n.toLocaleString(locale())
|
||||
const date = (iso: string) => new Intl.DateTimeFormat(locale(), { dateStyle: 'medium', timeStyle: 'short' }).format(entryTime(iso))
|
||||
const day = (key: string) => new Intl.DateTimeFormat(locale(), { dateStyle: 'medium' }).format(new Date(`${key}T00:00:00`))
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t('billing.title')} description={t('billing.description')} />
|
||||
<div className="metrics">
|
||||
<div className="metric">
|
||||
<span>{t('billing.total')}</span>
|
||||
<strong>{usage ? number(usage.total) : <Skeleton w={90} h={34} />}</strong>
|
||||
<small>{t('billing.period', { from: day(from), to: day(to) })}</small>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<span>{t('billing.balance')}</span>
|
||||
<strong>{balance.data != null ? number(balance.data) : <Skeleton w={90} h={34} />}</strong>
|
||||
<small>{t('billing.balanceSub')}</small>
|
||||
</div>
|
||||
</div>
|
||||
<section className="card">
|
||||
<div className="toolbar billing-toolbar">
|
||||
<h3>{t('billing.usage')} {usage && <span className="badge neutral">{t(usage.rows.length === 1 ? 'billing.countOne' : 'billing.count', { n: number(usage.rows.length) })}</span>}</h3>
|
||||
<div className="toolbar-controls billing-controls">
|
||||
<label className="billing-date">{t('billing.from')}
|
||||
<input type="date" value={from} max={to} onChange={e => e.target.value && pick(e.target.value, e.target.value > to ? e.target.value : to)} />
|
||||
</label>
|
||||
<label className="billing-date">{t('billing.to')}
|
||||
<input type="date" value={to} min={from} onChange={e => e.target.value && pick(e.target.value < from ? e.target.value : from, e.target.value)} />
|
||||
</label>
|
||||
<button className="btn" disabled={!usage?.rows.length}
|
||||
onClick={() => usage && download(`credit-usage-${from}-to-${to}.csv`, new Blob([usageCsv(usage.rows)], { type: 'text/csv;charset=utf-8' }))}>
|
||||
<Icon name="download" />{t('billing.export')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{ledger.isPending && <SkeletonRows label={t('billing.loading')} />}
|
||||
{ledger.isError && <Empty icon="info" title={t('billing.loadFailed')}>{ledger.error.message}</Empty>}
|
||||
{usage && usage.rows.length === 0 && <Empty icon="coin" title={t('billing.empty')}>{t('billing.emptyCopy')}</Empty>}
|
||||
{usage && usage.rows.length > 0 && (
|
||||
<div className="billing-scroll">
|
||||
<table className="billing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t('billing.colDate')}</th>
|
||||
<th scope="col">{t('billing.colUser')}</th>
|
||||
<th scope="col">{t('billing.colModule')}</th>
|
||||
<th scope="col" className="num">{t('billing.colCredits')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usage.rows.slice(0, shown).map(e => {
|
||||
const { module, detail } = moduleOf(e.activity)
|
||||
return (
|
||||
<tr key={e.id}>
|
||||
<td>{date(e.created_at)}</td>
|
||||
<td>{e.actor_name || '-'}</td>
|
||||
<td><b>{module}</b>{detail && <small>{detail}</small>}</td>
|
||||
<td className="num">
|
||||
{number(-e.amount)}
|
||||
{e.kind === 'refund' && <small>{t('billing.refund')}</small>}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{usage && usage.rows.length > shown && (
|
||||
<div className="billing-more"><button className="btn" onClick={() => setShown(s => s + PAGE)}>{t('billing.more')}</button></div>
|
||||
)}
|
||||
<div className="spacer" />
|
||||
<div className="inline-note"><Icon name="info" />{t('billing.note')}</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -210,3 +210,19 @@ textarea{resize:vertical;line-height:1.6}
|
||||
.compact-brain .brain-selection label{font-size:13px}
|
||||
.compact-brain .brain-selection{font-size:12px}
|
||||
.app-crash{max-width:560px;margin:12vh auto;padding-top:8px}
|
||||
|
||||
/* Billing: the credit ledger as a table, which scrolls by itself on a phone. */
|
||||
/* One row wherever it fits: without the shared toolbar's 520px cap, it only wraps when the room runs out. */
|
||||
.billing-toolbar{flex-wrap:wrap}
|
||||
.toolbar .billing-controls{width:auto;max-width:none;margin-left:auto;flex-wrap:wrap;justify-content:flex-end;align-items:center}
|
||||
.billing-date{display:flex;align-items:center;gap:8px;margin:0;white-space:nowrap}
|
||||
.billing-date input:not([type=checkbox]):not([type=radio]):not([type=file]){width:auto;height:40px;min-height:40px;padding-block:0}
|
||||
.billing-controls .btn{height:40px;min-height:40px;margin:0}
|
||||
.billing-scroll{overflow-x:auto}
|
||||
.billing-table{width:100%;min-width:560px;border-collapse:collapse;font-size:13.5px}
|
||||
.billing-table th{text-align:left;font-size:12px;font-weight:650;color:var(--muted);padding:10px 12px;border-bottom:1px solid var(--line)}
|
||||
.billing-table td{padding:12px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
.billing-table tbody tr:last-child td{border-bottom:0}
|
||||
.billing-table small{display:block;color:var(--muted);font-size:12px;margin-top:2px}
|
||||
.billing-table .num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.billing-more{display:flex;justify-content:center;padding-top:14px}
|
||||
|
||||
Reference in New Issue
Block a user