diff --git a/src/App.tsx b/src/App.tsx index a60ad72..4a861ee 100644 --- a/src/App.tsx +++ b/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 : +} + export default function App() { return ( @@ -17,6 +26,7 @@ export default function App() { } /> } /> } /> + } /> {/* Paths from before the routes were renamed, so old links keep working. */} } /> diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 63e02a6..59fc3f9 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -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() { - {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 [ diff --git a/src/express/billing.test.ts b/src/express/billing.test.ts new file mode 100644 index 0000000..461e1c0 --- /dev/null +++ b/src/express/billing.test.ts @@ -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' }) + }) +}) diff --git a/src/express/billing.ts b/src/express/billing.ts new file mode 100644 index 0000000..a80ccb8 --- /dev/null +++ b/src/express/billing.ts @@ -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 { + 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(`${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` +} diff --git a/src/i18n.ts b/src/i18n.ts index 3508bc6..5a2abc2 100644 --- a/src/i18n.ts +++ b/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 = { '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', diff --git a/src/pages/Billing.tsx b/src/pages/Billing.tsx new file mode 100644 index 0000000..74e2913 --- /dev/null +++ b/src/pages/Billing.tsx @@ -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 ( + <> + + + + {t('billing.total')} + {usage ? number(usage.total) : } + {t('billing.period', { from: day(from), to: day(to) })} + + + {t('billing.balance')} + {balance.data != null ? number(balance.data) : } + {t('billing.balanceSub')} + + + + + {t('billing.usage')} {usage && {t(usage.rows.length === 1 ? 'billing.countOne' : 'billing.count', { n: number(usage.rows.length) })}} + + {t('billing.from')} + e.target.value && pick(e.target.value, e.target.value > to ? e.target.value : to)} /> + + {t('billing.to')} + e.target.value && pick(e.target.value < from ? e.target.value : from, e.target.value)} /> + + usage && download(`credit-usage-${from}-to-${to}.csv`, new Blob([usageCsv(usage.rows)], { type: 'text/csv;charset=utf-8' }))}> + {t('billing.export')} + + + + {ledger.isPending && } + {ledger.isError && {ledger.error.message}} + {usage && usage.rows.length === 0 && {t('billing.emptyCopy')}} + {usage && usage.rows.length > 0 && ( + + + + + {t('billing.colDate')} + {t('billing.colUser')} + {t('billing.colModule')} + {t('billing.colCredits')} + + + + {usage.rows.slice(0, shown).map(e => { + const { module, detail } = moduleOf(e.activity) + return ( + + {date(e.created_at)} + {e.actor_name || '-'} + {module}{detail && {detail}} + + {number(-e.amount)} + {e.kind === 'refund' && {t('billing.refund')}} + + + ) + })} + + + + )} + {usage && usage.rows.length > shown && ( + setShown(s => s + PAGE)}>{t('billing.more')} + )} + + {t('billing.note')} + + > + ) +} diff --git a/src/styles/app.css b/src/styles/app.css index a42f6a6..3e7fe42 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -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}