g1t/apps/web/app/components/statement.tsx

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