| 1 | import { Link, data, redirect, useLocation } from "react-router"; |
| 2 | |
| 3 | import type { CreditKind, CreditMonth } from "@g1t/contracts"; |
| 4 | |
| 5 | import type { Route } from "./+types/credits"; |
| 6 | import { CreditList } from "~/components/billing"; |
| 7 | import { Button, Input, Notice, PageHeader, Section, Select, Stat } from "~/components/ui"; |
| 8 | import { monthLong } from "~/lib/chart"; |
| 9 | import { CREDIT_KINDS, isCreditKind, kindLabel, parseMonth } from "~/lib/credits"; |
| 10 | import { fields, parseNote, parseSlug } from "~/lib/forms"; |
| 11 | import { parseBy } from "~/lib/ledgers"; |
| 12 | import { usd } from "~/lib/money"; |
| 13 | import { DONE, doneKey } from "~/lib/review"; |
| 14 | import { admin } from "~/lib/services.server"; |
| 15 | import { settle } from "~/lib/settle"; |
| 16 | import { requireStaff } from "~/lib/staff"; |
| 17 | |
| 18 | export const meta: Route.MetaFunction = () => [{ title: "Credits & refunds · sudo" }, { name: "robots", content: "noindex, nofollow" }]; |
| 19 | |
| 20 | export 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 | |
| 42 | export 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. */ |
| 58 | function 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 | |
| 76 | export 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 | |
| 181 | function 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 | } |