flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/sudo/app/components/billing.tsx

653 lines28,856 bytesCodeBlame
1/**
2 * The billing sections shared by a workspace's page and an enterprise's:
3 * confirmations, terms, plan, pools and caps, credit, bank transfers, the
4 * Stripe billing link, the ledger and the audit log. Plain forms; sudo ships no JavaScript.
5 */
6import { CreditCard, Gift, Landmark, ScrollText, UserRound } from "lucide-react";
7import type { ReactNode } from "react";
8import { Link } from "react-router";
9
10import type { AdminAction, AdminOwner, Allowances, BillingLink, LedgerEntry, Terms } from "@g1t/contracts";
11
12import { Avatar, Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui";
13import { actionLabel } from "~/lib/ledgers";
14import { dollarsField, usd } from "~/lib/money";
15import { givenParts } from "~/lib/pricing";
16import type { Review, SectionError } from "~/lib/review";
17
18export function Hidden({ values }: { values: Record<string, string> }) {
19 return (
20 <>
21 {Object.entries(values).map(([name, value]) => (
22 <input key={name} type="hidden" name={name} value={value} />
23 ))}
24 </>
25 );
26}
27
28export function Figure({ label, value, hint }: { label: string; value: string; hint?: ReactNode }) {
29 return (
30 <div className="rounded-lg border border-line bg-surface px-4 py-3">
31 <p className="text-xs text-muted">{label}</p>
32 <p className="tabular mt-1 text-lg font-semibold tracking-tight">{value}</p>
33 {hint && <p className="mt-0.5 text-xs text-faint">{hint}</p>}
34 </div>
35 );
36}
37
38/** Owners' usernames, each with their email beneath (and on hover). */
39export function Owners({ owners, compact = false }: { owners: AdminOwner[]; compact?: boolean }) {
40 if (owners.length === 0) return <span className="text-faint">No owner</span>;
41 if (compact) {
42 return (
43 <span className="flex flex-col gap-0.5">
44 {owners.map((owner) => (
45 <span key={owner.username} title={owner.email ?? "No email"} className="min-w-0 truncate">
46 <span className="font-mono text-fg-soft">{owner.username}</span>
47 {owner.email && <span className="block truncate text-xs text-faint">{owner.email}</span>}
48 </span>
49 ))}
50 </span>
51 );
52 }
53 return (
54 <ul className="flex flex-wrap gap-2">
55 {owners.map((owner) => (
56 <li key={owner.username} title={owner.email ?? "No email"} className="inline-flex items-center gap-1.5 rounded-md border border-line bg-bg px-2 py-1 text-xs">
57 <Avatar name={owner.username} size={14} square={false} />
58 <span className="font-mono">{owner.username}</span>
59 {owner.email && <span className="text-faint">{owner.email}</span>}
60 </li>
61 ))}
62 </ul>
63 );
64}
65
66// --- Confirmation ------------------------------------------------------------
67
68function describeTerms(terms: Terms): [string, string][] {
69 return [
70 ["Terms", terms.kind === "custom" ? "Custom" : terms.kind === "comped" ? "Comped" : "Standard"],
71 ["Discount", terms.kind === "custom" ? `${terms.discountPercent}%` : "—"],
72 ["Limit", terms.ceilingMicros == null ? "By trust" : usd(terms.ceilingMicros)],
73 ["Until", terms.until ? terms.until.slice(0, 10) : "No end"],
74 ["Note", terms.note || "—"],
75 ];
76}
77
78/** Where a confirmed change lands, when its result is shown in place. */
79const RESULT_ANCHOR: Partial<Record<Review["intent"], string>> = { "billing-link": "billing-link", invoice: "invoices" };
80
81export function ReviewPanel({ review, pathname }: { review: Review; pathname: string }) {
82 let title: string;
83 let body: ReactNode;
84 let danger = false;
85 let confirm = "Confirm";
86 if (review.intent === "terms") {
87 const before = describeTerms(review.before);
88 const after = describeTerms(review.after);
89 title = "Confirm the new terms";
90 danger = review.after.kind === "comped";
91 body = (
92 <>
93 <div className="overflow-x-auto">
94 <table className="w-full text-sm">
95 <thead>
96 <tr className="text-left text-xs text-muted">
97 <th className="py-1.5 pr-4 font-medium" />
98 <th className="py-1.5 pr-4 font-medium">Now</th>
99 <th className="py-1.5 font-medium">After</th>
100 </tr>
101 </thead>
102 <tbody>
103 {after.map(([label, value], index) => (
104 <tr key={label} className="border-t border-line align-top">
105 <td className="py-1.5 pr-4 text-muted">{label}</td>
106 <td className="py-1.5 pr-4 break-words text-faint">{before[index][1]}</td>
107 <td className={`py-1.5 break-words ${value !== before[index][1] ? "font-medium text-fg" : "text-muted"}`}>{value}</td>
108 </tr>
109 ))}
110 </tbody>
111 </table>
112 </div>
113 {review.after.kind === "comped" && (
114 <p className="mt-3 text-sm text-warn">
115 Comped: nothing will be charged{review.after.until ? ` until ${review.after.until.slice(0, 10)}` : ""}. Usage is still recorded
116 at cost.
117 </p>
118 )}
119 </>
120 );
121 } else if (review.intent === "attach") {
122 title = `Bill ${review.workspace} to ${review.targetName}?`;
123 body = (
124 <p className="text-sm text-muted">
125 From now on <span className="font-mono text-fg">{review.workspace}</span>'s usage is billed to{" "}
126 <span className="text-fg">{review.targetName}</span> and counts against its limit and terms, not its own.
127 </p>
128 );
129 } else if (review.intent === "detach") {
130 title = `Move ${review.workspace} off ${review.from}?`;
131 danger = true;
132 body = (
133 <p className="text-sm text-muted">
134 <span className="font-mono text-fg">{review.workspace}</span> goes back to paying for itself, on its own terms and the limit
135 its trust gives it. It may stop at once if its own limit is lower than what it owes.
136 </p>
137 );
138 } else if (review.intent === "billing-email") {
139 title = `Send ${review.name}'s invoices to ${review.after}?`;
140 body = (
141 <p className="text-sm text-muted">
142 Stripe emails its invoices to <span className="font-mono text-fg">{review.after}</span>
143 {review.before ? (
144 <>
145 {" "}
146 instead of <span className="font-mono">{review.before}</span>
147 </>
148 ) : null}
149 , from the next invoice on.{!review.before && " This also sets the enterprise up as a customer on Stripe."}
150 </p>
151 );
152 } else if (review.intent === "invoice") {
153 title = `Invoice ${review.name} now?`;
154 confirm = "Send the invoice";
155 body = (
156 <ul className="list-disc space-y-1 pl-5 text-sm text-muted">
157 <li>Makes one Stripe invoice, with a line for each of its {review.workspaces} workspace{review.workspaces === 1 ? "" : "s"} for what it owes now.</li>
158 <li>Net 30: due in 30 days.</li>
159 <li>
160 Stripe emails it to <span className="font-mono text-fg">{review.email ?? "the invoice email (none set yet)"}</span>, with a link to
161 pay on Stripe's page.
162 </li>
163 </ul>
164 );
165 } else {
166 title = `Make a Stripe billing link for ${review.workspace}?`;
167 confirm = "Make the link";
168 body = (
169 <p className="text-sm text-muted">
170 Opens a one-time session on Stripe's billing page for <span className="font-mono text-fg">{review.workspace}</span>'s
171 customer, where they update their card and see their invoices. Anyone with the link can use it until it expires, so send it
172 only to the workspace's owner. Making it is recorded with your email.
173 </p>
174 );
175 }
176 return (
177 <section id="review" className={`scroll-mt-20 rounded-lg border p-4 sm:p-5 ${danger ? "border-warn/40 bg-warn/5" : "border-merged/40 bg-merged/5"}`}>
178 <h2 className="font-semibold tracking-tight">{title}</h2>
179 <div className="mt-3">{body}</div>
180 <form method="post" action={`${pathname}#${RESULT_ANCHOR[review.intent] ?? "top"}`} className="mt-4 flex flex-wrap items-center gap-2">
181 <Hidden values={review.fields} />
182 <input type="hidden" name="intent" value={review.intent} />
183 <input type="hidden" name="confirm" value="yes" />
184 <Button type="submit" variant={danger ? "danger" : "lavender"}>
185 {confirm}
186 </Button>
187 <Link to={pathname} className="px-2 text-sm text-muted hover:text-fg">
188 Cancel
189 </Link>
190 </form>
191 </section>
192 );
193}
194
195// --- Terms -------------------------------------------------------------------
196
197const KINDS: { value: Terms["kind"]; title: string; text: string }[] = [
198 { value: "standard", title: "Standard", text: "Published prices; the limit comes from trust." },
199 { value: "comped", title: "Comped", text: "Nothing charged. Usage still recorded at cost." },
200 { value: "custom", title: "Custom", text: "A discount, a custom limit, or both." },
201];
202
203export function TermsForm({ terms, pathname, error }: { terms: Terms; pathname: string; error: SectionError }) {
204 const values = error?.values;
205 const kind = values?.kind ?? terms.kind;
206 return (
207 <Section
208 id="terms"
209 title="Terms"
210 description={
211 terms.setBy ? (
212 <>
213 Set by <span className="font-mono">{terms.setBy}</span> on <When at={terms.setAt} />
214 {terms.note && <> · “{terms.note}”</>}
215 </>
216 ) : (
217 "Standard, as everyone starts."
218 )
219 }
220 >
221 <form method="post" action={`${pathname}#review`} className="space-y-4">
222 <input type="hidden" name="intent" value="terms" />
223 {error && <Notice tone="error">{error.error}</Notice>}
224 <fieldset>
225 <legend className="mb-1.5 text-sm font-medium text-muted">Kind</legend>
226 <div className="grid gap-2 sm:grid-cols-3">
227 {KINDS.map((option) => (
228 <label
229 key={option.value}
230 className="flex cursor-pointer gap-2.5 rounded-md border border-line bg-bg p-3 transition-colors hover:border-line-strong has-checked:border-merged/60 has-checked:bg-merged/8"
231 >
232 <input type="radio" name="kind" value={option.value} defaultChecked={kind === option.value} className="mt-0.5" required />
233 <span>
234 <span className="block text-sm font-medium">{option.title}</span>
235 <span className="mt-0.5 block text-xs text-muted">{option.text}</span>
236 </span>
237 </label>
238 ))}
239 </div>
240 </fieldset>
241 <div className="grid gap-4 sm:grid-cols-3">
242 <Field label="Discount %" hint="Custom only.">
243 <Input name="discount" inputMode="numeric" pattern="\d{1,3}" placeholder="0" defaultValue={values?.discount ?? (terms.discountPercent ? String(terms.discountPercent) : "")} />
244 </Field>
245 <Field label="Limit $" hint="Unpaid usage allowed. Blank: trust decides.">
246 <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} />
247 </Field>
248 <Field label="Until" hint="Blank: no end. UTC.">
249 <Input type="date" name="until" defaultValue={values?.until ?? (terms.until ? terms.until.slice(0, 10) : "")} />
250 </Field>
251 </div>
252 <Field label="Note" hint="Required. Why, for whoever looks next.">
253 <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. Design partner through launch" />
254 </Field>
255 <div className="flex justify-end">
256 <Button type="submit">Review terms</Button>
257 </div>
258 </form>
259 </Section>
260 );
261}
262
263// --- Plan, pools and caps -------------------------------------------------------
264
265/** What staff have set beyond the terms, one line each; defaults left out. */
266export function allowanceLines(allowances: Allowances | undefined, comped: boolean): string[] {
267 const lines: string[] = [];
268 if (comped) lines.push("The g1t plan, without its price (comped)");
269 else if (allowances?.plan) lines.push("The g1t plan, without its price");
270 if (!allowances) return lines;
271 if (allowances.ossRepoMicros != null) lines.push(`Open-source pool: ${usd(allowances.ossRepoMicros)} a month for each public repository`);
272 if (allowances.trialMicros != null) lines.push(`Trial: ${usd(allowances.trialMicros)}, outside the monthly pool`);
273 if (allowances.maxConcurrentAgents != null) {
274 lines.push(`${allowances.maxConcurrentAgents} agent${allowances.maxConcurrentAgents === 1 ? "" : "s"} at once`);
275 }
276 if (allowances.runCapMicros != null) lines.push(`Run cap: ${usd(allowances.runCapMicros)} a run`);
277 if (allowances.issueCapMicros != null) lines.push(`Issue cap: ${usd(allowances.issueCapMicros)} an issue`);
278 if (allowances.hold) lines.push(`Held: ${allowances.hold}`);
279 return lines;
280}
281
282/**
283 * The g1t plan without its price, the account's share of g1t's pools, and
284 * staff's overrides of what owners set: agents at once, the run and issue
285 * caps, and a hold on new compute. Comped accounts have the plan anyway.
286 */
287export function AllowancesForm({
288 allowances,
289 comped,
290 pathname,
291 error,
292}: {
293 allowances: Allowances | undefined;
294 comped: boolean;
295 pathname: string;
296 error: SectionError;
297}) {
298 const values = error?.values;
299 const current: Allowances = allowances ?? { plan: false, ossRepoMicros: null, trialMicros: null };
300 const lines = allowanceLines(allowances, comped);
301 return (
302 <Section
303 id="allowances"
304 title="Plan, pools and caps"
305 description={
306 comped
307 ? "Comped: the g1t plan is on without its price whatever is set here. The pools and caps still apply."
308 : "The g1t plan without its price, this account's share of g1t's pools, and overrides of what owners set."
309 }
310 >
311 <div className="mb-4 rounded-md border border-line bg-bg px-3.5 py-2.5 text-sm">
312 <p className="text-xs text-faint">Set now</p>
313 {lines.length === 0 ? (
314 <p className="mt-0.5 text-muted">Nothing: the defaults.</p>
315 ) : (
316 <ul className="mt-1 space-y-0.5">
317 {lines.map((line) => (
318 <li key={line} className={line.startsWith("Held:") ? "text-danger" : "text-fg-soft"}>
319 {line}
320 </li>
321 ))}
322 </ul>
323 )}
324 </div>
325 <form method="post" action={`${pathname}#allowances`} className="space-y-4">
326 <input type="hidden" name="intent" value="allowances" />
327 {error && <Notice tone="error">{error.error}</Notice>}
328 <label className="flex cursor-pointer items-start gap-2.5 text-sm">
329 <input type="checkbox" name="plan" defaultChecked={values ? values.plan === "on" : current.plan} className="mt-0.5" />
330 <span>
331 <span className="block font-medium">The g1t plan, without its price</span>
332 <span className="block text-xs text-muted">
333 Its included usage, storage and caps, with no $20 a month. Usage past it is charged as usual.
334 </span>
335 </span>
336 </label>
337 <div className="grid gap-4 sm:grid-cols-2">
338 <Field label="Open-source share $" hint="Each public repository, a month. Blank: the default ($2).">
339 <Input name="oss" inputMode="decimal" placeholder="Default" defaultValue={values?.oss ?? dollarsField(current.ossRepoMicros)} />
340 </Field>
341 <Field label="Trial $" hint="Each workspace, outside the monthly pool. Blank: the default ($5).">
342 <Input name="trial" inputMode="decimal" placeholder="Default" defaultValue={values?.trial ?? dollarsField(current.trialMicros)} />
343 </Field>
344 <Field label="Agents at once" hint="Blank: the plan's (2 in the first month or on the trial, then 10).">
345 <Input
346 name="agents"
347 inputMode="numeric"
348 pattern="\d{1,3}"
349 placeholder="Default"
350 defaultValue={values?.agents ?? (current.maxConcurrentAgents != null ? String(current.maxConcurrentAgents) : "")}
351 />
352 </Field>
353 <Field label="Run cap $" hint="One run's spend, over the owners'. Blank: theirs, or $2.">
354 <Input name="runCap" inputMode="decimal" placeholder="Owners'" defaultValue={values?.runCap ?? dollarsField(current.runCapMicros)} />
355 </Field>
356 <Field label="Issue cap $" hint="One issue's agents in all, over the owners'. Blank: theirs, or $10.">
357 <Input name="issueCap" inputMode="decimal" placeholder="Owners'" defaultValue={values?.issueCap ?? dollarsField(current.issueCapMicros)} />
358 </Field>
359 <Field label="Hold" hint="Pauses new compute and tells the owners why. Blank: no hold.">
360 <Input name="hold" maxLength={200} placeholder="No hold" defaultValue={values?.hold ?? current.hold ?? ""} />
361 </Field>
362 </div>
363 <Field label="Note" hint="Required. Why, for whoever looks next.">
364 <Textarea
365 name="note"
366 rows={2}
367 required
368 maxLength={500}
369 defaultValue={values?.note ?? ""}
370 placeholder="e.g. Open-source foundation, larger share through 2027"
371 />
372 </Field>
373 <div className="flex justify-end">
374 <Button type="submit">Save plan, pools and caps</Button>
375 </div>
376 </form>
377 </Section>
378 );
379}
380
381// --- Bank transfer ---------------------------------------------------------------
382
383/**
384 * Money that reached g1t outside Stripe's page, such as a bank transfer
385 * for a $1,000+ prepayment, entered as a payment.
386 */
387export function PaymentForm({ workspace, pathname, error }: { workspace: string; pathname: string; error: SectionError }) {
388 const values = error?.values;
389 return (
390 <Section
391 id="payment"
392 title="Record a bank transfer"
393 description="Money that reached g1t's bank without Stripe's page, such as a $1,000+ prepayment. It goes on the statement as a payment and counts toward the limit at once."
394 >
395 <form method="post" action={`${pathname}#payment`} className="space-y-4">
396 <input type="hidden" name="intent" value="payment" />
397 {error && <Notice tone="error">{error.error}</Notice>}
398 <div className="grid gap-4 sm:grid-cols-2">
399 <Field label="Amount $" hint="As it arrived, up to $100,000.">
400 <Input name="amount" inputMode="decimal" required placeholder="1,500.00" defaultValue={values?.amount ?? ""} />
401 </Field>
402 <Field label="Reference" hint="As the bank shows it. Each is recorded once.">
403 <Input name="reference" required maxLength={100} className="font-mono" defaultValue={values?.reference ?? ""} />
404 </Field>
405 </div>
406 <Field label="Note" hint="Required. Shown on the statement after “Paid by bank transfer”.">
407 <Textarea
408 name="note"
409 rows={2}
410 required
411 maxLength={500}
412 placeholder="e.g. Prepayment for October, invoice INV-0042"
413 defaultValue={values?.note ?? ""}
414 />
415 </Field>
416 <Field
417 label="Confirm"
418 hint={
419 <>
420 Type the workspace's slug (<span className="font-mono text-muted">{workspace}</span>) to record it.
421 </>
422 }
423 >
424 <Input name="confirmation" required placeholder={workspace} className="font-mono" />
425 </Field>
426 <div className="flex justify-end">
427 <Button type="submit" variant="lavender">
428 <Landmark size={14} />
429 Record the payment
430 </Button>
431 </div>
432 </form>
433 </Section>
434 );
435}
436
437// --- Credit -------------------------------------------------------------------
438
439export function CreditForm({ workspaces, pathname, error }: { workspaces: string[]; pathname: string; error: SectionError }) {
440 const values = error?.values;
441 const single = workspaces.length === 1 ? workspaces[0] : null;
442 return (
443 <Section id="credit" title="Issue credit" description="A refund or goodwill. Added to the workspace's balance at once.">
444 {workspaces.length === 0 ? (
445 <p className="text-sm text-muted">Add a workspace first: credit goes to a workspace.</p>
446 ) : (
447 <form method="post" action={`${pathname}#credit`} className="space-y-4">
448 <input type="hidden" name="intent" value="credit" />
449 {error && <Notice tone="error">{error.error}</Notice>}
450 {single ? (
451 <input type="hidden" name="workspace" value={single} />
452 ) : (
453 <Field label="Workspace">
454 <Select name="workspace" required defaultValue={values?.workspace ?? ""}>
455 <option value="" disabled>
456 Choose a workspace
457 </option>
458 {workspaces.map((slug) => (
459 <option key={slug} value={slug}>
460 {slug}
461 </option>
462 ))}
463 </Select>
464 </Field>
465 )}
466 <Field label="Amount $" hint="Up to $10,000 at a time.">
467 <Input name="amount" inputMode="decimal" required placeholder="25.00" defaultValue={values?.amount ?? ""} />
468 </Field>
469 <Field label="Note" hint="Required. Shown on the workspace's statement.">
470 <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Refund for the failed runs on Oct 2" defaultValue={values?.note ?? ""} />
471 </Field>
472 <Field
473 label="Confirm"
474 hint={
475 <>
476 Type the workspace's slug{single && <> (<span className="font-mono text-muted">{single}</span>)</>} to issue it.
477 </>
478 }
479 >
480 <Input name="confirmation" required placeholder={single ?? "workspace-slug"} className="font-mono" />
481 </Field>
482 <div className="flex justify-end">
483 <Button type="submit" variant="lavender">
484 <Gift size={14} />
485 Issue credit
486 </Button>
487 </div>
488 </form>
489 )}
490 </Section>
491 );
492}
493
494// --- Stripe billing link -----------------------------------------------------
495
496/**
497 * Card details, invoices and receipts live on Stripe's own billing page.
498 * sudo never shows a card field: staff send the customer a link instead.
499 */
500export function BillingLinkSection({
501 link,
502 pathname,
503 error,
504}: {
505 link: BillingLink | null;
506 pathname: string;
507 error: SectionError;
508}) {
509 return (
510 <Section
511 id="billing-link"
512 title="Stripe billing page"
513 description="Where the customer updates their card and sees invoices. g1t never takes card numbers."
514 >
515 {error && (
516 <div className="mb-4">
517 <Notice tone="error">{error.error}</Notice>
518 </div>
519 )}
520 {link ? (
521 <div className="space-y-4">
522 <Notice tone="info">Send this to the customer. g1t never takes card numbers; they enter them on Stripe.</Notice>
523 <Field label="One-time link" hint={link.expiresNote}>
524 <Input readOnly value={link.portalUrl} className="font-mono text-xs" aria-label="One-time Stripe billing link" />
525 </Field>
526 {link.loginUrl && (
527 <Field label="Permanent sign-in page" hint={`The customer signs in with ${link.customerEmail ?? "the email Stripe has for them"}.`}>
528 <Input readOnly value={link.loginUrl} className="font-mono text-xs" aria-label="Stripe billing sign-in page" />
529 </Field>
530 )}
531 {!link.loginUrl && link.customerEmail && <p className="text-xs text-faint">Stripe has {link.customerEmail} for this customer.</p>}
532 </div>
533 ) : (
534 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
535 <input type="hidden" name="intent" value="billing-link" />
536 <p className="text-sm text-muted">
537 Never take card details over the phone or by email. Make a link to Stripe's billing page and send it to the owner.
538 </p>
539 <Button type="submit" variant="quiet" className="shrink-0">
540 <CreditCard size={14} />
541 Make a link
542 </Button>
543 </form>
544 )}
545 </Section>
546 );
547}
548
549// --- Ledger and audit ---------------------------------------------------------
550
551const ENTRY_KIND: Record<string, string> = { top_up: "Top-up", usage: "Usage", credit: "Credit" };
552
553export function LedgerSection({
554 ledger,
555 showWorkspace = false,
556 description = "Recent lines of the statement, newest first.",
557}: {
558 ledger: LedgerEntry[];
559 showWorkspace?: boolean;
560 description?: ReactNode;
561}) {
562 return (
563 <Section title="Ledger" description={description}>
564 {ledger.length === 0 ? (
565 <EmptyState title="Nothing yet" />
566 ) : (
567 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
568 <table className="w-full min-w-[36rem] text-sm">
569 <thead>
570 <tr className="border-b border-line text-left text-xs text-muted">
571 <th className="px-4 py-2 font-medium sm:pl-5">When</th>
572 <th className="px-4 py-2 font-medium">What</th>
573 <th className="px-4 py-2 text-right font-medium sm:pr-5">Amount</th>
574 </tr>
575 </thead>
576 <tbody>
577 {ledger.map((entry) => (
578 <tr key={entry.id} className="border-b border-line align-top last:border-0">
579 <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pl-5">
580 <When at={entry.createdAt} time />
581 </td>
582 <td className="px-4 py-2.5">
583 <div className="flex flex-wrap items-center gap-1.5">
584 <Badge tone={entry.amountMicros > 0 ? "mint" : "plain"}>{ENTRY_KIND[entry.kind] ?? entry.kind}</Badge>
585 {showWorkspace && entry.workspace && (
586 <Link to={`/workspaces/${encodeURIComponent(entry.workspace)}`} className="font-mono text-xs text-merged hover:underline">
587 {entry.workspace}
588 </Link>
589 )}
590 <span className="break-words">{entry.description}</span>
591 {givenParts(entry).map((part) => (
592 <Badge key={part.label} tone="lavender">
593 Given · {part.label} {usd(part.micros)}
594 </Badge>
595 ))}
596 </div>
597 <p className="mt-0.5 font-mono text-xs text-faint">
598 {[
599 entry.repo && (entry.number != null ? `${entry.repo}#${entry.number}` : entry.repo),
600 entry.task,
601 entry.model,
602 entry.billedTo === "workspace" ? "own provider" : null,
603 entry.createdBy && `by ${entry.createdBy}`,
604 ]
605 .filter(Boolean)
606 .join(" · ")}
607 </p>
608 </td>
609 <td className={`tabular px-4 py-2.5 text-right whitespace-nowrap sm:pr-5 ${entry.amountMicros > 0 ? "text-accent" : "text-fg-soft"}`}>
610 {usd(entry.amountMicros, { signed: true })}
611 </td>
612 </tr>
613 ))}
614 </tbody>
615 </table>
616 </div>
617 )}
618 </Section>
619 );
620}
621
622
623
624export function AuditSection({ audit, description = "Every change made in sudo, and by whom." }: { audit: AdminAction[]; description?: ReactNode }) {
625 return (
626 <Section title="Audit log" description={description}>
627 {audit.length === 0 ? (
628 <p className="flex items-center gap-2 text-sm text-muted">
629 <ScrollText size={14} />
630 No changes yet.
631 </p>
632 ) : (
633 <ol className="space-y-3">
634 {audit.map((entry) => (
635 <li key={entry.id} className="border-l-2 border-merged/40 pl-3">
636 <p className="flex flex-wrap items-center gap-x-2 text-sm">
637 <span className="font-medium text-merged">{actionLabel(entry.action)}</span>
638 <span className="text-xs text-faint">
639 <When at={entry.createdAt} time />
640 </span>
641 </p>
642 {entry.detail && <p className="mt-0.5 text-sm break-words text-fg-soft">{entry.detail}</p>}
643 <p className="mt-0.5 flex items-center gap-1 font-mono text-xs text-faint">
644 <UserRound size={11} />
645 {entry.by}
646 </p>
647 </li>
648 ))}
649 </ol>
650 )}
651 </Section>
652 );
653}