g1t/apps/sudo/app/routes/requests.tsx
| 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 | } |