flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

241 lines11,721 bytesCodeBlame
1import { Check, Clock, X } from "lucide-react";
2import { Link, data, redirect, useLocation } from "react-router";
3
4import type { LimitRequestReview } from "@g1t/contracts";
5
6import type { Route } from "./+types/requests";
7import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Textarea, When } from "~/components/ui";
8import { fields, parseDecision } from "~/lib/forms";
9import { usd } from "~/lib/money";
10import { REQUEST_STATUSES, type RequestStatus, age, parseRequestStatus, requestsHref, waiting } from "~/lib/pricing";
11import { DONE, doneKey } from "~/lib/review";
12import { admin } from "~/lib/services.server";
13import { settle } from "~/lib/settle";
14import { requireStaff } from "~/lib/staff";
15
16export const meta: Route.MetaFunction = () => [{ title: "Requests · sudo" }, { name: "robots", content: "noindex, nofollow" }];
17
18export 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
37export 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
51const STATUS_LABEL: Record<RequestStatus, string> = { open: "Open", approved: "Approved", declined: "Declined", all: "All" };
52
53export 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
100function 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}