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/web/app/components/statement.tsx

248 lines9,379 bytesCodeBlame
1import { ChevronRight, Download } from "lucide-react";
2import { useState } from "react";
3import { Link, useFetcher, useNavigate } from "react-router";
4
5import { MICROS_PER_DOLLAR, type LedgerEntry, type Statement } from "@g1t/contracts";
6
7import { EmptyState, TimeAgo } from "./ui";
8import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
9import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
10
11/** Millionths of a dollar as dollars, to the cent or finer. */
12function dollars(micros: number, digits = 2): string {
13 const sign = micros < 0 ? "−" : "";
14 return `${sign}$${(Math.abs(micros) / MICROS_PER_DOLLAR).toFixed(digits)}`;
15}
16
17/** Charges to the cent, but a fraction of a cent shown as such rather than $0.00. */
18function charge(micros: number): string {
19 return micros !== 0 && Math.abs(micros) < 10_000 ? dollars(micros, 4) : dollars(micros);
20}
21
22function monthLabel(month: string): string {
23 const [year, number] = month.split("-").map(Number);
24 return new Date(Date.UTC(year, number - 1, 1)).toLocaleDateString("en-US", {
25 month: "long",
26 year: "numeric",
27 timeZone: "UTC",
28 });
29}
30
31function dayLabel(day: string): string {
32 return new Date(`${day}T00:00:00Z`).toLocaleDateString("en-US", {
33 weekday: "short",
34 month: "short",
35 day: "numeric",
36 timeZone: "UTC",
37 });
38}
39
40/**
41 * A month of the workspace's ledger: totals, then a group per day (or per
42 * project) with one line per kind of charge. A line opens to its entries,
43 * 50 at a time.
44 */
45export function StatementView({
46 slug,
47 statement,
48 group,
49}: {
50 slug: string;
51 statement: Statement;
52 group: "day" | "project";
53}) {
54 const navigate = useNavigate();
55 const go = (month: string, by: string) =>
56 navigate(`/${slug}/-/billing?month=${month}&group=${by}#statement`, { preventScrollReset: true });
57 const months = statement.months.includes(statement.month) ? statement.months : [statement.month, ...statement.months];
58 const { totals } = statement;
59
60 return (
61 <section id="statement" className="mt-10 scroll-mt-20">
62 <div className="flex flex-wrap items-center gap-3">
63 <h3 className="grow text-sm font-medium text-muted">Statement</h3>
64 <Select value={statement.month} onValueChange={(month) => go(month, group)}>
65 <SelectTrigger size="sm" className="w-40" aria-label="Month">
66 <SelectValue />
67 </SelectTrigger>
68 <SelectContent>
69 {months.map((month) => (
70 <SelectItem key={month} value={month}>
71 {monthLabel(month)}
72 </SelectItem>
73 ))}
74 </SelectContent>
75 </Select>
76 <Tabs value={group} onValueChange={(by) => go(statement.month, by)}>
77 <TabsList>
78 <TabsTrigger value="day">By day</TabsTrigger>
79 <TabsTrigger value="project">By project</TabsTrigger>
80 </TabsList>
81 </Tabs>
82 {totals.entries > 0 && (
83 <a
84 href={`/${slug}/-/billing/statement.csv?month=${statement.month}`}
85 className="flex h-8 items-center gap-1.5 rounded-md border border-line px-2.5 text-[0.8125rem] text-muted hover:border-line-strong hover:text-fg"
86 download
87 >
88 <Download size={13} /> CSV
89 </a>
90 )}
91 </div>
92
93 <dl className="mt-3 grid grid-cols-3 divide-x divide-line rounded-xl border border-line bg-surface text-sm">
94 <div className="px-4 py-3">
95 <dt className="text-xs text-faint">Charged</dt>
96 <dd className="mt-0.5 font-mono tabular-nums">{charge(totals.chargedMicros)}</dd>
97 </div>
98 <div className="px-4 py-3">
99 <dt className="text-xs text-faint">Paid and credited</dt>
100 <dd className="mt-0.5 font-mono tabular-nums">{dollars(totals.paidMicros)}</dd>
101 </div>
102 <div className="px-4 py-3">
103 <dt className="text-xs text-faint">Entries</dt>
104 <dd className="mt-0.5 font-mono tabular-nums">{totals.entries.toLocaleString("en-US")}</dd>
105 </div>
106 </dl>
107
108 <div className="mt-3">
109 {statement.groups.length === 0 ? (
110 <EmptyState title={`Nothing in ${monthLabel(statement.month)}`}>
111 Agent runs, sandbox time, deployments and payments appear here, a line per kind each day.
112 </EmptyState>
113 ) : (
114 <div className="overflow-hidden rounded-xl border border-line">
115 {statement.groups.map((g) => (
116 <div key={g.key} className="border-b border-line last:border-b-0">
117 <div className="flex items-center gap-4 bg-surface px-4 py-2 text-xs">
118 <span className="grow truncate font-medium text-muted">
119 {group === "day" ? dayLabel(g.key) : g.label}
120 </span>
121 <span className="font-mono tabular-nums text-faint">{charge(g.chargedMicros)}</span>
122 </div>
123 <ul className="divide-y divide-line">
124 {g.lines.map((line) => (
125 <StatementLineRow
126 key={line.kind}
127 slug={slug}
128 month={statement.month}
129 kind={line.kind}
130 count={line.count}
131 chargedMicros={line.chargedMicros}
132 day={group === "day" ? g.key : null}
133 project={group === "project" ? g.key : null}
134 />
135 ))}
136 </ul>
137 </div>
138 ))}
139 </div>
140 )}
141 </div>
142 </section>
143 );
144}
145
146function StatementLineRow({
147 slug,
148 month,
149 kind,
150 count,
151 chargedMicros,
152 day,
153 project,
154}: {
155 slug: string;
156 month: string;
157 kind: string;
158 count: number;
159 chargedMicros: number;
160 day: string | null;
161 project: string | null;
162}) {
163 const [open, setOpen] = useState(false);
164 const [entries, setEntries] = useState<LedgerEntry[]>([]);
165 const [done, setDone] = useState(false);
166 const fetcher = useFetcher<LedgerEntry[]>();
167 const [seen, setSeen] = useState<LedgerEntry[] | undefined>(undefined);
168
169 // Each page that arrives is added once.
170 if (fetcher.data && fetcher.data !== seen) {
171 setSeen(fetcher.data);
172 setEntries((before) => [...before, ...fetcher.data!]);
173 if (fetcher.data.length < 50) setDone(true);
174 }
175
176 const load = (before: string | null) => {
177 const query = new URLSearchParams({ month, kind });
178 if (day) query.set("day", day);
179 if (project !== null) query.set("project", project);
180 if (before) query.set("before", before);
181 fetcher.load(`/${slug}/-/billing/entries?${query}`);
182 };
183
184 const toggle = () => {
185 if (!open && entries.length === 0) load(null);
186 setOpen(!open);
187 };
188 const moneyIn = chargedMicros < 0;
189
190 return (
191 <li>
192 <button
193 type="button"
194 onClick={toggle}
195 aria-expanded={open}
196 className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm hover:bg-surface/60"
197 >
198 <ChevronRight size={14} className={`shrink-0 text-faint transition-transform ${open ? "rotate-90" : ""}`} />
199 <span className="grow truncate font-medium">{kind}</span>
200 <span className="shrink-0 text-xs text-faint tabular-nums">
201 {count.toLocaleString("en-US")} {count === 1 ? "entry" : "entries"}
202 </span>
203 <span
204 className={`w-24 shrink-0 text-right font-mono tabular-nums ${moneyIn ? "text-accent" : "text-fg"}`}
205 >
206 {moneyIn ? `+${dollars(-chargedMicros)}` : charge(chargedMicros)}
207 </span>
208 </button>
209 {open && (
210 <div className="border-t border-line bg-bg/40 pb-2">
211 <ul className="divide-y divide-line/60">
212 {entries.map((entry) => (
213 <li key={entry.id} className="flex items-center gap-4 py-2 pr-4 pl-11 text-[0.8125rem]">
214 <div className="min-w-0 grow">
215 {entry.repo && entry.number ? (
216 <Link to={`/${entry.repo}/pull/${entry.number}`} className="block truncate hover:underline">
217 {entry.description}
218 </Link>
219 ) : (
220 <p className="truncate">{entry.description}</p>
221 )}
222 <p className="mt-0.5 text-xs text-faint">
223 <TimeAgo at={entry.createdAt} />
224 {entry.model && ` · ${entry.model}`}
225 {entry.createdBy && ` · ${entry.createdBy}`}
226 </p>
227 </div>
228 <span className="shrink-0 font-mono text-xs tabular-nums text-muted">
229 {entry.amountMicros > 0 ? `+${dollars(entry.amountMicros)}` : charge(-entry.amountMicros)}
230 </span>
231 </li>
232 ))}
233 </ul>
234 {fetcher.state === "loading" && <p className="py-2 pl-11 text-xs text-faint">Loading…</p>}
235 {!done && entries.length > 0 && entries.length < count && fetcher.state === "idle" && (
236 <button
237 type="button"
238 onClick={() => load(entries[entries.length - 1].id)}
239 className="mt-1 ml-11 text-xs text-muted hover:text-fg"
240 >
241 Show {Math.min(50, count - entries.length)} more of {count - entries.length}
242 </button>
243 )}
244 </div>
245 )}
246 </li>
247 );
248}