Skip to content

g1t/apps/sudo/app/routes/credits.tsx

210 lines9,928 bytesCodeBlame
1import { Link, data, redirect, useLocation } from "react-router";
2
3import type { CreditKind, CreditMonth } from "@g1t/contracts";
4
5import type { Route } from "./+types/credits";
6import { CreditList } from "~/components/billing";
7import { Button, Input, Notice, PageHeader, Section, Select, Stat } from "~/components/ui";
8import { monthLong } from "~/lib/chart";
9import { CREDIT_KINDS, isCreditKind, kindLabel, parseMonth } from "~/lib/credits";
10import { fields, parseNote, parseSlug } from "~/lib/forms";
11import { parseBy } from "~/lib/ledgers";
12import { usd } from "~/lib/money";
13import { DONE, doneKey } from "~/lib/review";
14import { admin } from "~/lib/services.server";
15import { settle } from "~/lib/settle";
16import { requireStaff } from "~/lib/staff";
17
18export const meta: Route.MetaFunction = () => [{ title: "Credits & refunds · sudo" }, { name: "robots", content: "noindex, nofollow" }];
19
20export async function loader({ request, context }: Route.LoaderArgs) {
21 requireStaff(context);
22 const url = new URL(request.url);
23 const kindParam = url.searchParams.get("kind") ?? "";
24 const kind: CreditKind | null = isCreditKind(kindParam) ? kindParam : null;
25 const month = parseMonth(url.searchParams.get("month"));
26 const by = parseBy(url.searchParams.get("by"));
27 const slug = parseSlug(url.searchParams.get("workspace") ?? "");
28 const workspace = slug.ok ? slug.value : null;
29 const result = await settle(admin.credits({ kind, month, by, workspace }));
30 const done = doneKey(request.url);
31 return {
32 kind,
33 month,
34 by,
35 workspace,
36 credits: result.ok ? result.value : null,
37 error: result.ok ? null : result.error,
38 done: done ? DONE[done] : null,
39 };
40}
41
42export async function action({ request, context }: Route.ActionArgs) {
43 const staff = requireStaff(context);
44 const form = await request.formData();
45 const values = fields(form, "id", "reason", "workspace");
46 const fail = (error: string) => data({ error, section: "credits", values }, { status: 422 });
47 if (values.id === "" || form.get("intent") !== "revoke-credit") return fail("Unknown action.");
48 const reason = parseNote(values.reason);
49 if (!reason.ok) return fail(reason.error);
50 const result = await admin.revokeCredit(values.id, reason.value, staff.email);
51 if (!result.ok) return fail(result.error.message);
52 const back = new URL(request.url);
53 back.searchParams.set("done", "revoked");
54 throw redirect(`${back.pathname}${back.search}#top`);
55}
56
57/** Each month's kinds, with a total line when there is more than one. */
58function byMonth(months: CreditMonth[]): { month: string; rows: CreditMonth[]; total: CreditMonth }[] {
59 const out: { month: string; rows: CreditMonth[]; total: CreditMonth }[] = [];
60 for (const row of months) {
61 let group = out.find((g) => g.month === row.month);
62 if (!group) {
63 group = { month: row.month, rows: [], total: { ...row, kind: row.kind, givenMicros: 0, grants: 0, usedMicros: 0, expiredMicros: 0, revokedMicros: 0 } };
64 out.push(group);
65 }
66 group.rows.push(row);
67 group.total.givenMicros += row.givenMicros;
68 group.total.grants += row.grants;
69 group.total.usedMicros += row.usedMicros;
70 group.total.expiredMicros += row.expiredMicros;
71 group.total.revokedMicros += row.revokedMicros;
72 }
73 return out;
74}
75
76export default function Credits({ loaderData, actionData }: Route.ComponentProps) {
77 const { kind, month, by, workspace, credits, error, done } = loaderData;
78 const { pathname, search } = useLocation();
79 const failed = actionData && "error" in actionData ? { error: actionData.error, values: actionData.values } : null;
80 const filtered = kind != null || month != null || by != null || workspace != null;
81 const thisMonth = new Date().toISOString().slice(0, 7);
82 const now = byMonth(credits?.months ?? []).find((g) => g.month === thisMonth)?.total;
83 const open = (credits?.grants ?? []).filter((grant) => grant.state === "open");
84
85 return (
86 <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
87 <PageHeader
88 title="Credits & refunds"
89 description="Every credit staff have given: promotional and goodwill credit is given away when spent; a refund gives back money already paid. Give credit from a workspace's page, under Billing."
90 />
91 {done && (
92 <div className="mt-6">
93 <Notice tone="ok">{done}</Notice>
94 </div>
95 )}
96 {error ? (
97 <div className="mt-6">
98 <Notice tone="warn">Billing did not answer for credits: {error}</Notice>
99 </div>
100 ) : (
101 <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
102 <Stat label="Given this month" value={usd(now?.givenMicros ?? 0, { cents: true })} hint={`${now?.grants ?? 0} credit${now?.grants === 1 ? "" : "s"}`} />
103 <Stat label="Spent this month" value={usd(now?.usedMicros ?? 0, { cents: true })} hint="On usage, every kind" />
104 <Stat label="Taken back this month" value={usd((now?.expiredMicros ?? 0) + (now?.revokedMicros ?? 0), { cents: true })} hint="Expired or revoked unused" />
105 <Stat
106 label="Left to spend"
107 value={usd(open.reduce((sum, grant) => sum + grant.leftMicros, 0), { cents: true })}
108 hint={`${open.length} open credit${open.length === 1 ? "" : "s"}${filtered ? ", filtered" : ""}`}
109 />
110 </div>
111 )}
112
113 {credits && credits.months.length > 0 && (
114 <Section className="mt-6" title="By month" description="The last 12 months by kind. Spent is what credit paid for that month, whenever it was given.">
115 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
116 <table className="w-full min-w-[34rem] text-sm">
117 <thead>
118 <tr className="border-b border-line text-left text-xs text-muted">
119 <th className="px-4 py-2 font-medium sm:pl-5">Month</th>
120 <th className="px-4 py-2 font-medium">Kind</th>
121 <th className="px-4 py-2 text-right font-medium">Given</th>
122 <th className="px-4 py-2 text-right font-medium">Spent</th>
123 <th className="px-4 py-2 text-right font-medium">Expired</th>
124 <th className="px-4 py-2 text-right font-medium sm:pr-5">Revoked</th>
125 </tr>
126 </thead>
127 <tbody>
128 {byMonth(credits.months).map((group) => (
129 <MonthRows key={group.month} group={group} />
130 ))}
131 </tbody>
132 </table>
133 </div>
134 </Section>
135 )}
136
137 <form method="get" action="/credits" className="mt-6 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
138 <Select name="kind" defaultValue={kind ?? ""} aria-label="Kind" className="sm:w-40">
139 <option value="">Any kind</option>
140 {CREDIT_KINDS.map((option) => (
141 <option key={option.value} value={option.value}>
142 {option.title}
143 </option>
144 ))}
145 </Select>
146 <Input type="month" name="month" defaultValue={month ?? ""} aria-label="Month given" className="sm:w-44" />
147 <Select name="by" defaultValue={by ?? ""} aria-label="Given by" className="sm:w-56">
148 <option value="">Anyone</option>
149 {(credits?.staff ?? []).map((email) => (
150 <option key={email} value={email}>
151 {email}
152 </option>
153 ))}
154 </Select>
155 <Input name="workspace" defaultValue={workspace ?? ""} placeholder="Workspace" aria-label="Workspace" className="font-mono sm:w-44" />
156 <div className="flex items-center gap-2">
157 <Button type="submit" variant="quiet" className="py-2">
158 Apply
159 </Button>
160 {filtered && (
161 <Link to="/credits" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline">
162 Clear
163 </Link>
164 )}
165 </div>
166 </form>
167
168 {credits && (
169 <Section
170 className="mt-4"
171 title={filtered ? "Credits that match" : "Every credit"}
172 description={`Newest first${credits.grants.length >= 200 ? ", the newest 200" : ""}. Revoking takes back what is left; what was spent stays spent.`}
173 >
174 <CreditList grants={credits.grants} pathname={`${pathname}${search}`} error={failed} showWorkspace />
175 </Section>
176 )}
177 </main>
178 );
179}
180
181function MonthRows({ group }: { group: { month: string; rows: CreditMonth[]; total: CreditMonth } }) {
182 const cell = "tabular px-4 py-2 text-right";
183 return (
184 <>
185 {group.rows.map((row, index) => (
186 <tr key={row.kind} className={index === 0 ? "border-t border-line" : ""}>
187 <td className="px-4 py-2 sm:pl-5">{index === 0 ? monthLong(group.month) : ""}</td>
188 <td className="px-4 py-2 text-fg-soft">
189 {kindLabel(row.kind)}
190 <span className="text-faint"> · {row.grants}</span>
191 </td>
192 <td className={cell}>{usd(row.givenMicros, { cents: true })}</td>
193 <td className={cell}>{usd(row.usedMicros, { cents: true })}</td>
194 <td className={`${cell} text-muted`}>{usd(row.expiredMicros, { cents: true })}</td>
195 <td className={`${cell} text-muted sm:pr-5`}>{usd(row.revokedMicros, { cents: true })}</td>
196 </tr>
197 ))}
198 {group.rows.length > 1 && (
199 <tr className="text-muted">
200 <td className="px-4 py-2 sm:pl-5" />
201 <td className="px-4 py-2 text-xs">All kinds</td>
202 <td className={cell}>{usd(group.total.givenMicros, { cents: true })}</td>
203 <td className={cell}>{usd(group.total.usedMicros, { cents: true })}</td>
204 <td className={cell}>{usd(group.total.expiredMicros, { cents: true })}</td>
205 <td className={`${cell} sm:pr-5`}>{usd(group.total.revokedMicros, { cents: true })}</td>
206 </tr>
207 )}
208 </>
209 );
210}