g1t/apps/sudo/app/routes/requests.tsx
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1 | import { Check, Clock, X } from "lucide-react"; |
| 2 | import { Link, data, redirect, useLocation } from "react-router"; | |
| 3 | ||
| 4 | import type { LimitRequestReview } from "@g1t/contracts"; | |
| 5 | ||
| 6 | import type { Route } from "./+types/requests"; | |
| 7 | import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Textarea, When } from "~/components/ui"; | |
| 8 | import { fields, parseDecision } from "~/lib/forms"; | |
| 9 | import { usd } from "~/lib/money"; | |
| 10 | import { REQUEST_STATUSES, type RequestStatus, age, parseRequestStatus, requestsHref, waiting } from "~/lib/pricing"; | |
| 11 | import { DONE, doneKey } from "~/lib/review"; | |
| 12 | import { admin } from "~/lib/services.server"; | |
| 13 | import { settle } from "~/lib/settle"; | |
| 14 | import { requireStaff } from "~/lib/staff"; | |
| 15 | ||
| 16 | export const meta: Route.MetaFunction = () => [{ title: "Requests · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 17 | ||
| 18 | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 19 | requireStaff(context); | |
| 20 | const url = new URL(request.url); | |
| 21 | const status = parseRequestStatus(url.searchParams.get("status")); | |
| 22 | const [rows, open] = await Promise.all([ | |
| 23 | settle(admin.limitRequests(status)), | |
| 24 | status === "open" ? Promise.resolve(null) : settle(admin.limitRequests("open")), | |
| 25 | ]); | |
| 26 | const done = doneKey(request.url); | |
| 27 | return { | |
| 28 | status, | |
| 29 | rows: rows.ok ? rows.value : [], | |
| 30 | openCount: status === "open" ? (rows.ok ? rows.value.length : 0) : open?.ok ? open.value.length : null, | |
| 31 | error: rows.ok ? null : rows.error, | |
| 32 | done: done ? DONE[done] : null, | |
| 33 | now: new Date().toISOString(), | |
| 34 | }; | |
| 35 | } | |
| 36 | ||
| 37 | export async function action({ request, context }: Route.ActionArgs) { | |
| 38 | const staff = requireStaff(context); | |
| 39 | const form = await request.formData(); | |
| 40 | const values = fields(form, "id", "amount", "note"); | |
| 41 | const decision = parseDecision(form); | |
| 42 | if (!decision.ok) return data({ error: decision.error, id: values.id, values }, { status: 422 }); | |
| 43 | const { id, amountMicros, note } = decision.value; | |
| 44 | const result = await admin.decideLimitRequest(id, decision.value.decision, amountMicros, note, staff.email); | |
| 45 | if (!result.ok) return data({ error: result.error.message, id, values }, { status: 422 }); | |
| 46 | const back = new URL(request.url); | |
| 47 | back.searchParams.set("done", decision.value.decision === "approve" ? "approved" : "declined"); | |
| 48 | throw redirect(`${back.pathname}${back.search}`); | |
| 49 | } | |
| 50 | ||
| 51 | const STATUS_LABEL: Record<RequestStatus, string> = { open: "Open", approved: "Approved", declined: "Declined", all: "All" }; | |
| 52 | ||
| 53 | export default function Requests({ loaderData, actionData }: Route.ComponentProps) { | |
| 54 | const { status, rows, openCount, error, done, now } = loaderData; | |
| 55 | return ( | |
| 56 | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 57 | <PageHeader | |
| 58 | title="Requests" | |
| 59 | description="Owners asking for a higher limit, or for help with a month that went past what they meant. g1t answers within one business day; the owner is told in the app and by email." | |
| 60 | /> | |
| 61 | <nav aria-label="Status" className="mt-5 flex flex-wrap gap-2"> | |
| 62 | {REQUEST_STATUSES.map((value) => ( | |
| 63 | <Link | |
| 64 | key={value} | |
| 65 | to={requestsHref(value)} | |
| 66 | aria-current={value === status ? "page" : undefined} | |
| 67 | className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors ${ | |
| 68 | value === status ? "border-merged/50 bg-merged/10 text-merged" : "border-line text-muted hover:border-line-strong hover:text-fg" | |
| 69 | }`} | |
| 70 | > | |
| 71 | {STATUS_LABEL[value]} | |
| 72 | {value === "open" && openCount != null && <span className="tabular text-faint">{openCount}</span>} | |
| 73 | </Link> | |
| 74 | ))} | |
| 75 | </nav> | |
| 76 | <div className="mt-5 space-y-3 empty:hidden"> | |
| 77 | {done && <Notice tone="ok">{done}</Notice>} | |
| 78 | {error && <Notice tone="warn">Billing did not answer for requests: {error}</Notice>} | |
| 79 | </div> | |
| 80 | <div className="mt-5 space-y-4"> | |
| 81 | {rows.length === 0 && !error ? ( | |
| 82 | <EmptyState title={status === "open" ? "Nothing waiting" : `No ${STATUS_LABEL[status].toLowerCase()} requests`}> | |
| 83 | Requests show up here when an owner uses Raise my limit, or tells g1t a month went past what they meant. | |
| 84 | </EmptyState> | |
| 85 | ) : ( | |
| 86 | rows.map((row) => ( | |
| 87 | <RequestCard | |
| 88 | key={row.request.id} | |
| 89 | row={row} | |
| 90 | now={now} | |
| 91 | error={actionData && "id" in actionData && actionData.id === row.request.id ? actionData : null} | |
| 92 | /> | |
| 93 | )) | |
| 94 | )} | |
| 95 | </div> | |
| 96 | </main> | |
| 97 | ); | |
| 98 | } | |
| 99 | ||
| 100 | function RequestCard({ | |
| 101 | row, | |
| 102 | now, | |
| 103 | error, | |
| 104 | }: { | |
| 105 | row: LimitRequestReview; | |
| 106 | now: string; | |
| 107 | error: { error: string; values?: Record<string, string> } | null; | |
| 108 | }) { | |
| 109 | const { request, history } = row; | |
| 110 | const { pathname, search } = useLocation(); | |
| 111 | const wait = waiting(request.createdAt, new Date(now)); | |
| 112 | const open = request.status === "open"; | |
| 113 | const overage = request.kind === "overage"; | |
| 114 | const workspaceAge = age(history.firstSeen, new Date(now)); | |
| 115 | const facts: [string, string][] = [ | |
| 116 | ["Plan", history.plan ?? "—"], | |
| 117 | ["Seen for", workspaceAge ?? "—"], | |
| 118 | ["Paid, cleared", `${usd(history.paidClearedMicros)} in ${history.payments} payment${history.payments === 1 ? "" : "s"}`], | |
| 119 | ["Disputes · declines", `${history.disputes} · ${history.declines}`], | |
| 120 | ["Ceiling now", usd(history.ceilingMicros)], | |
| 121 | ["Highest ceiling", usd(history.maxCeilingMicros)], | |
| 122 | ["Owners' spend limit", history.spendLimitMicros == null ? "Automatic or none" : usd(history.spendLimitMicros)], | |
| 123 | ["Last hour · usual hour", `${usd(history.lastHourMicros)} · ${usd(history.averageHourMicros)}`], | |
| 124 | ["Last day", usd(history.lastDayMicros)], | |
| 125 | ]; | |
| 126 | return ( | |
| 127 | <section | |
| 128 | id={request.id} | |
| 129 | className={`scroll-mt-20 rounded-lg border bg-surface ${open && wait.overdue ? "border-danger/40" : "border-line"}`} | |
| 130 | > | |
| 131 | <header className="flex flex-wrap items-start justify-between gap-3 border-b border-line px-4 py-3 sm:px-5"> | |
| 132 | <div className="min-w-0"> | |
| 133 | <p className="flex flex-wrap items-center gap-2"> | |
| 134 | <Link to={`/workspaces/${encodeURIComponent(request.workspace)}#billing`} className="font-mono font-medium text-fg hover:underline"> | |
| 135 | {request.workspace} | |
| 136 | </Link> | |
| 137 | <Badge tone={overage ? "warn" : "lavender"}>{overage ? "Spent more than meant" : "Raise my limit"}</Badge> | |
| 138 | {request.status === "approved" && <Badge tone="mint">Approved{request.decidedMicros != null ? ` at ${usd(request.decidedMicros)}` : ""}</Badge>} | |
| 139 | {request.status === "declined" && <Badge tone="danger">Declined</Badge>} | |
| 140 | </p> | |
| 141 | <p className="mt-0.5 text-xs text-faint"> | |
| 142 | By <span className="font-mono">{request.createdBy}</span> · <When at={request.createdAt} time /> | |
| 143 | </p> | |
| 144 | </div> | |
| 145 | {open && ( | |
| 146 | <p className={`flex items-center gap-1.5 text-xs ${wait.overdue ? "text-danger" : "text-muted"}`}> | |
| 147 | <Clock size={13} /> | |
| 148 | Waiting {wait.label} | |
| 149 | {wait.overdue ? " · past one business day" : ""} | |
| 150 | </p> | |
| 151 | )} | |
| 152 | </header> | |
| 153 | <div className="grid gap-5 p-4 sm:p-5 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"> | |
| 154 | <div className="min-w-0 space-y-4"> | |
| 155 | {!overage && ( | |
| 156 | <dl className="grid grid-cols-2 gap-3"> | |
| 157 | <div> | |
| 158 | <dt className="text-xs text-muted">Asked for</dt> | |
| 159 | <dd className="tabular mt-0.5 text-lg font-semibold">{usd(request.amountMicros)}</dd> | |
| 160 | </div> | |
| 161 | <div> | |
| 162 | <dt className="text-xs text-muted">Expects to spend a month</dt> | |
| 163 | <dd className="tabular mt-0.5 text-lg font-semibold">{usd(request.expectedMonthlyMicros)}</dd> | |
| 164 | </div> | |
| 165 | </dl> | |
| 166 | )} | |
| 167 | <blockquote className="border-l-2 border-line-strong pl-3 text-sm break-words whitespace-pre-line text-fg-soft">{request.reason}</blockquote> | |
| 168 | {request.answer && ( | |
| 169 | <p className="text-sm text-muted"> | |
| 170 | Answer{request.decidedBy ? <> by <span className="font-mono">{request.decidedBy}</span></> : null}: {request.answer} | |
| 171 | </p> | |
| 172 | )} | |
| 173 | {history.months.length > 0 && ( | |
| 174 | <div className="overflow-x-auto"> | |
| 175 | <table className="w-full text-xs"> | |
| 176 | <thead> | |
| 177 | <tr className="text-left text-faint"> | |
| 178 | <th className="py-1 pr-3 font-medium">Month</th> | |
| 179 | <th className="py-1 pr-3 text-right font-medium">Charged</th> | |
| 180 | <th className="py-1 pr-3 text-right font-medium">Cost</th> | |
| 181 | <th className="py-1 text-right font-medium">Paid</th> | |
| 182 | </tr> | |
| 183 | </thead> | |
| 184 | <tbody> | |
| 185 | {history.months.map((month) => ( | |
| 186 | <tr key={month.month} className="border-t border-line"> | |
| 187 | <td className="py-1 pr-3 font-mono text-muted">{month.month}</td> | |
| 188 | <td className="tabular py-1 pr-3 text-right">{usd(month.chargedMicros)}</td> | |
| 189 | <td className="tabular py-1 pr-3 text-right text-muted">{usd(month.costMicros)}</td> | |
| 190 | <td className="tabular py-1 text-right">{usd(month.paidMicros)}</td> | |
| 191 | </tr> | |
| 192 | ))} | |
| 193 | </tbody> | |
| 194 | </table> | |
| 195 | </div> | |
| 196 | )} | |
| 197 | {open && ( | |
| 198 | <form method="post" action={`${pathname}${search}#${request.id}`} className="space-y-3 border-t border-line pt-4"> | |
| 199 | <input type="hidden" name="id" value={request.id} /> | |
| 200 | {error && <Notice tone="error">{error.error}</Notice>} | |
| 201 | {overage ? ( | |
| 202 | <p className="text-sm text-muted"> | |
| 203 | Usually answered with a goodwill credit on{" "} | |
| 204 | <Link to={`/overages#${encodeURIComponent(request.workspace)}`} className="text-merged hover:underline"> | |
| 205 | Overages | |
| 206 | </Link> | |
| 207 | , which also closes this request. Or answer it here. | |
| 208 | </p> | |
| 209 | ) : ( | |
| 210 | <Field label="Approve at $" hint={`Blank: the ${usd(request.amountMicros)} asked for. Or type a different amount.`}> | |
| 211 | <Input name="amount" inputMode="decimal" placeholder={String(request.amountMicros / 1_000_000)} defaultValue={error?.values?.amount ?? ""} /> | |
| 212 | </Field> | |
| 213 | )} | |
| 214 | <Field label="Note for the owner" hint="Required to decline: they read it in the app and by email."> | |
| 215 | <Textarea name="note" rows={2} maxLength={500} defaultValue={error?.values?.note ?? ""} placeholder="e.g. Approved for the launch; it grows with your payments from here." /> | |
| 216 | </Field> | |
| 217 | <div className="flex flex-wrap justify-end gap-2"> | |
| 218 | <Button type="submit" name="decision" value="decline" variant="danger"> | |
| 219 | <X size={14} /> | |
| 220 | Decline | |
| 221 | </Button> | |
| 222 | <Button type="submit" name="decision" value="approve" variant="lavender"> | |
| 223 | <Check size={14} /> | |
| 224 | Approve | |
| 225 | </Button> | |
| 226 | </div> | |
| 227 | </form> | |
| 228 | )} | |
| 229 | </div> | |
| 230 | <dl className="space-y-1.5 text-xs"> | |
| 231 | {facts.map(([label, value]) => ( | |
| 232 | <div key={label} className="grid grid-cols-[9rem_minmax(0,1fr)] gap-2"> | |
| 233 | <dt className="text-faint">{label}</dt> | |
| 234 | <dd className="tabular text-fg-soft">{value}</dd> | |
| 235 | </div> | |
| 236 | ))} | |
| 237 | </dl> | |
| 238 | </div> | |
| 239 | </section> | |
| 240 | ); | |
| 241 | } |