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

637 lines27,422 bytesCodeBlame
1import { ArrowLeft, Building2, LogOut } from "lucide-react";
2import type { ReactNode } from "react";
3import { data, Link, useLocation } from "react-router";
4
5import { type AccountSummary, type AdminAction, type Entitlements, type Limit, type Overage, type Terms, httpStatus } from "@g1t/contracts";
6
7import type { Route } from "./+types/workspace";
8import {
9 AllowancesForm,
10 AuditSection,
11 BillingLinkSection,
12 CreditForm,
13 Figure,
14 LedgerSection,
15 Owners,
16 PaymentForm,
17 ReviewPanel,
18 TermsForm,
19} from "~/components/billing";
20import { MonthsChart } from "~/components/charts";
21import { SalesSection, StageBadge, WorkspaceInvoicesSection } from "~/components/sales";
22import {
23 Avatar,
24 Badge,
25 Button,
26 EmptyState,
27 ExposureBar,
28 Field,
29 Input,
30 Notice,
31 Section,
32 Select,
33 StateBadge,
34 TermsBadge,
35 Textarea,
36 TrustBadge,
37 When,
38 trustAbout,
39} from "~/components/ui";
40import { type Subject, billingAction } from "~/lib/billing-actions.server";
41import { parseSlug, text } from "~/lib/forms";
42import { usd } from "~/lib/money";
43import { type ActionData, DONE, type SectionError, doneKey } from "~/lib/review";
44import { SALES_INTENTS, salesAction } from "~/lib/sales-actions.server";
45import { goodwillWarning, spikeLabel } from "~/lib/pricing";
46import { admin, entitlements as entitlementsOf, identity, priceBook } from "~/lib/services.server";
47import { settle } from "~/lib/settle";
48import { requireStaff } from "~/lib/staff";
49import type { Enterprise } from "~/lib/workspaces";
50
51export const meta: Route.MetaFunction = ({ loaderData }) => [
52 { title: `${loaderData?.name ?? "Workspace"} · sudo` },
53 { name: "robots", content: "noindex, nofollow" },
54];
55
56/** Whether an audit line is about `slug`, in an enterprise's log. */
57function mentions(action: AdminAction, slug: string): boolean {
58 // A slug is letters, digits and hyphens, none special in a pattern.
59 return new RegExp(`(^|[^a-z0-9-])${slug}($|[^a-z0-9-])`).test(action.detail.toLowerCase());
60}
61
62async function load(raw: string) {
63 const parsed = parseSlug(raw);
64 if (!parsed.ok) throw data("That is not a workspace slug.", { status: 404 });
65 const slug = parsed.value;
66 const [person, result] = await Promise.all([identity.workspace(slug), admin.account(slug)]);
67 if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) });
68 const detail = result.value;
69 const { account } = detail.summary;
70 const billedTo: Enterprise | null = account.kind === "enterprise" ? { id: account.id, name: account.name } : null;
71 const known = person != null || billedTo != null || account.createdAt !== "" || detail.ledger.length > 0 || detail.audit.length > 0;
72 if (!known) throw data(`There is no workspace called ${slug}.`, { status: 404 });
73 const subject: Subject = { kind: "workspace", slug, accountId: account.id, terms: account.terms, billedTo };
74 return { slug, person, detail, billedTo, subject };
75}
76
77/** Sales and invoices are newer billing methods; a page still opens without them. */
78const SALES_DONE = new Set(["sales", "note"]);
79
80export async function loader({ request, params, context }: Route.LoaderArgs) {
81 const staff = requireStaff(context);
82 const { slug, person, detail, billedTo, subject } = await load(params.slug);
83 const { summary } = detail;
84 // On an enterprise, the limit is the enterprise's and the figures are
85 // this workspace's share of its bill.
86 const limit = (billedTo && detail.workspaces.find((member) => member.workspace === slug)) || summary.limit;
87 const share = billedTo ? summary.byWorkspace?.find((row) => row.workspace === slug) : null;
88 const figures = billedTo
89 ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 }
90 : { charged: summary.chargedMicros, cost: summary.costMicros, paid: summary.paidMicros };
91 const [enterprises, sales, invoices, plan, overages, book] = await Promise.all([
92 billedTo
93 ? Promise.resolve([])
94 : admin
95 .accounts()
96 .then((rows: AccountSummary[]) =>
97 rows
98 .filter((row) => row.account.kind === "enterprise")
99 .map((row) => ({ id: row.account.id, name: row.account.name, members: row.account.workspaces.length })),
100 ),
101 settle(admin.sales(slug)),
102 settle(admin.workspaceInvoices(slug)),
103 settle(entitlementsOf(slug)),
104 settle(admin.overages()),
105 settle(priceBook()),
106 ]);
107 const done = doneKey(request.url);
108 return {
109 me: staff.email,
110 slug,
111 name: person?.name ?? slug,
112 person,
113 billedTo,
114 terms: subject.terms,
115 allowances: summary.account.allowances,
116 limit,
117 figures,
118 months: summary.months ?? [],
119 enterprises,
120 sales: sales.ok ? sales.value : null,
121 salesError: sales.ok ? null : sales.error,
122 invoices: invoices.ok ? invoices.value : [],
123 invoicesError: invoices.ok ? null : invoices.error,
124 entitlements: plan.ok ? plan.value : null,
125 entitlementsError: plan.ok ? null : plan.error,
126 overage: overages.ok ? (overages.value.find((row) => row.workspace === slug) ?? null) : null,
127 forgiveCap: (book.ok && book.value.free?.overageForgiveCostMicros) || 50_000_000,
128 ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger,
129 audit: billedTo ? detail.audit.filter((entry) => mentions(entry, slug)) : detail.audit,
130 done: done && !SALES_DONE.has(done) ? DONE[done] : null,
131 salesDone: done && SALES_DONE.has(done) ? DONE[done] : null,
132 };
133}
134
135export async function action({ request, params, context }: Route.ActionArgs) {
136 const staff = requireStaff(context);
137 // What is acted on comes from billing and identity, not from the form.
138 const { slug, subject } = await load(params.slug);
139 const path = `/workspaces/${encodeURIComponent(slug)}`;
140 const form = await request.clone().formData();
141 if (SALES_INTENTS.has(text(form, "intent"))) return salesAction(form, staff, slug, path);
142 return billingAction(request, staff, subject, path);
143}
144
145const SECTIONS = [
146 { id: "members", label: "Members" },
147 { id: "sales", label: "Sales" },
148 { id: "plan", label: "Plan" },
149 { id: "billing", label: "Billing" },
150 { id: "invoices", label: "Invoices" },
151 { id: "ledger", label: "Ledger" },
152 { id: "audit", label: "Audit log" },
153];
154
155export default function Workspace({ loaderData, actionData }: Route.ComponentProps) {
156 const {
157 me,
158 slug,
159 name,
160 person,
161 billedTo,
162 terms,
163 allowances,
164 limit,
165 figures,
166 months,
167 enterprises,
168 sales,
169 salesError,
170 invoices,
171 invoicesError,
172 entitlements,
173 entitlementsError,
174 overage,
175 forgiveCap,
176 ledger,
177 audit,
178 done,
179 salesDone,
180 } = loaderData;
181 const { pathname } = useLocation();
182 const result = actionData as ActionData | undefined;
183 const review = result && "review" in result ? result.review : null;
184 const link = result && "link" in result ? result.link : null;
185 const error = (section: string): SectionError => (result && "error" in result && result.section === section ? result : null);
186 const owners = person?.members.filter((member) => member.role === "owner") ?? [];
187
188 return (
189 <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
190 <Link to="/workspaces" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
191 <ArrowLeft size={14} />
192 Workspaces
193 </Link>
194
195 {/* Header */}
196 <div className="mt-4 flex flex-wrap items-start justify-between gap-4">
197 <div className="flex min-w-0 items-start gap-3">
198 <Avatar name={slug} size={40} />
199 <div className="min-w-0">
200 <h1 className="truncate text-2xl font-semibold tracking-tight">{name}</h1>
201 <p className="mt-0.5 text-xs text-faint">
202 <span className="font-mono">{slug}</span>
203 {person && (
204 <>
205 {" · "}created <When at={person.createdAt} />
206 </>
207 )}
208 </p>
209 {person?.description && <p className="mt-1 text-sm text-muted">{person.description}</p>}
210 <div className="mt-2 flex flex-wrap gap-1.5">
211 {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>}
212 <TermsBadge terms={terms} />
213 {terms.kind !== "comped" && <TrustBadge trust={limit.trust} />}
214 <StateBadge state={limit.state} />
215 {sales && sales.stage !== "none" && <StageBadge stage={sales.stage} />}
216 </div>
217 </div>
218 </div>
219 {owners.length > 0 && (
220 <div className="text-xs">
221 <p className="mb-1.5 text-faint">Owners</p>
222 <Owners owners={owners} />
223 </div>
224 )}
225 </div>
226
227 <nav aria-label="On this page" className="mt-6 -mx-4 overflow-x-auto px-4">
228 <ul className="flex gap-1 border-b border-line text-sm">
229 {SECTIONS.map((section) => (
230 <li key={section.id}>
231 <a href={`#${section.id}`} className="block border-b-2 border-transparent px-2.5 py-2 whitespace-nowrap text-muted transition-colors hover:border-line-strong hover:text-fg">
232 {section.label}
233 </a>
234 </li>
235 ))}
236 </ul>
237 </nav>
238
239 <div className="mt-6 space-y-3 empty:hidden">
240 {done && <Notice tone="ok">{done}</Notice>}
241 {error("top") && <Notice tone="error">{error("top")?.error}</Notice>}
242 {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>}
243 {review && <ReviewPanel review={review} pathname={pathname} />}
244 </div>
245
246 {/* People */}
247 <Section id="members" title="Members" description="Everyone in the workspace. Owners manage its members and billing." className="mt-6">
248 {!person ? (
249 <Notice tone="warn">Identity has no record of {slug}; only billing knows it. It may have been deleted.</Notice>
250 ) : person.members.length === 0 ? (
251 <EmptyState title="No members" />
252 ) : (
253 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
254 <table className="w-full min-w-120 text-sm">
255 <thead>
256 <tr className="border-b border-line text-left text-xs text-muted">
257 <th className="px-4 py-2 font-medium sm:pl-5">Member</th>
258 <th className="px-4 py-2 font-medium">Email</th>
259 <th className="px-4 py-2 font-medium">Role</th>
260 <th className="px-4 py-2 font-medium sm:pr-5">Joined</th>
261 </tr>
262 </thead>
263 <tbody>
264 {person.members.map((member) => (
265 <tr key={member.username} className="border-b border-line last:border-0">
266 <td className="px-4 py-2.5 sm:pl-5">
267 <span className="inline-flex items-center gap-2 font-mono">
268 <Avatar name={member.username} size={18} square={false} />
269 <Link to={`/users/${member.username}`} className="hover:underline">
270 {member.username}
271 </Link>
272 </span>
273 </td>
274 <td className="px-4 py-2.5 break-all text-muted">{member.email ?? <span className="text-faint">—</span>}</td>
275 <td className="px-4 py-2.5">{member.role === "owner" ? <Badge tone="lavender">Owner</Badge> : <Badge>Member</Badge>}</td>
276 <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pr-5">
277 <When at={member.joined} />
278 </td>
279 </tr>
280 ))}
281 </tbody>
282 </table>
283 </div>
284 )}
285 </Section>
286
287 {/* Sales */}
288 <div className="mt-6">
289 <SalesSection
290 sales={sales}
291 unavailable={salesError}
292 me={me}
293 pathname={pathname}
294 salesError={error("sales")}
295 noteError={error("note")}
296 done={salesDone}
297 />
298 </div>
299
300 {/* Plan */}
301 <div className="mt-6">
302 <PlanSection entitlements={entitlements} unavailable={entitlementsError} />
303 </div>
304
305 {/* Billing */}
306 <h2 id="billing" className="mt-10 scroll-mt-20 text-lg font-semibold tracking-tight">
307 Billing
308 </h2>
309 <div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
310 <LimitCard limit={limit} terms={terms} billedTo={billedTo} />
311 <Figure label="Charged this month" value={usd(figures.charged)} hint={`Cost to g1t ${usd(figures.cost)}`} />
312 <Figure label="Paid ever" value={usd(figures.paid)} hint={`Margin this month ${usd(figures.charged - figures.cost)}`} />
313 </div>
314
315 <Section
316 title="Last six months"
317 description={billedTo ? `${billedTo.name}'s figures: every workspace it pays for, together.` : "What it was charged, against what its usage cost g1t."}
318 className="mt-6"
319 >
320 <MonthsChart wide months={months} label={`${billedTo ? billedTo.name : name}: charged and cost by month`} />
321 </Section>
322
323 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
324 <div className="space-y-6">
325 <BilledToSection billedTo={billedTo} enterprises={enterprises} pathname={pathname} error={error("billed-to")} />
326 {billedTo ? (
327 <Section id="terms" title="Terms" description={`Charged on ${billedTo.name}'s terms while it pays for this workspace.`}>
328 <div className="flex flex-wrap items-center justify-between gap-3">
329 <TermsBadge terms={terms} />
330 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}#terms`} className="text-sm text-merged hover:underline hover:underline-offset-4">
331 Change them on {billedTo.name}
332 </Link>
333 </div>
334 </Section>
335 ) : (
336 <>
337 <TermsForm terms={terms} pathname={pathname} error={error("terms")} />
338 <AllowancesForm allowances={allowances} comped={terms.kind === "comped"} pathname={pathname} error={error("allowances")} />
339 </>
340 )}
341 <WorkspaceInvoicesSection invoices={invoices} unavailable={invoicesError} />
342 <div id="ledger" className="scroll-mt-20">
343 <LedgerSection
344 ledger={ledger}
345 description={billedTo ? `This workspace's lines among ${billedTo.name}'s latest 100.` : "Recent lines of the statement, newest first."}
346 />
347 </div>
348 </div>
349
350 <div className="space-y-6">
351 <BillingLinkSection link={link} pathname={pathname} error={error("billing-link")} />
352 {!billedTo && terms.kind !== "comped" && (
353 <GoodwillForm overage={overage} cap={forgiveCap} pathname={pathname} error={error("goodwill")} />
354 )}
355 <PaymentForm workspace={slug} pathname={pathname} error={error("payment")} />
356 <CreditForm workspaces={[slug]} pathname={pathname} error={error("credit")} />
357 <div id="audit" className="scroll-mt-20">
358 <AuditSection
359 audit={audit}
360 description={
361 billedTo ? (
362 <>
363 Changes about this workspace in {billedTo.name}'s log.{" "}
364 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="text-merged hover:underline">
365 Full log
366 </Link>
367 </>
368 ) : undefined
369 }
370 />
371 </div>
372 </div>
373 </div>
374 </main>
375 );
376}
377
378/**
379 * The limit, and in words how it is made: trust, the owners' own spend
380 * limit (or the default), the most they may set, and how it grows.
381 */
382function LimitCard({ limit, terms, billedTo }: { limit: Limit; terms: Terms; billedTo: Enterprise | null }) {
383 const lines: { label: string; value: ReactNode }[] = [];
384 if (billedTo) {
385 lines.push({ label: "Limit", value: <>{billedTo.name}'s, shared by every workspace it pays for</> });
386 } else if (terms.kind !== "comped") {
387 lines.push({ label: "From trust", value: <>{usd(limit.trustCeilingMicros)}. {trustAbout(limit.trust)}</> });
388 }
389 if (terms.ceilingMicros != null) lines.push({ label: "Custom limit", value: usd(terms.ceilingMicros) });
390 if (!billedTo && terms.kind !== "comped") {
391 lines.push({
392 label: "Owners' limit",
393 value: limit.defaultSpendLimit ? (
394 <>The default, {usd(limit.spendLimitMicros ?? 200_000_000)} a month: they have not chosen one</>
395 ) : limit.spendLimitMicros == null ? (
396 "None of their own: they use everything available"
397 ) : (
398 <>{usd(limit.spendLimitMicros)} a month, chosen by the owners</>
399 ),
400 });
401 if (limit.availableMicros !== undefined) {
402 lines.push({
403 label: "Available",
404 value:
405 limit.availableMicros == null ? (
406 "No ceiling"
407 ) : (
408 <>Up to {usd(limit.availableMicros)}: the most owners may set; past it, they contact g1t</>
409 ),
410 });
411 }
412 }
413 if (limit.growth) lines.push({ label: "Grows", value: limit.growth });
414
415 return (
416 <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2">
417 <p className="mb-2 text-xs text-muted">
418 Usage this month against the limit{billedTo && <> · shared with every workspace {billedTo.name} pays for</>}
419 </p>
420 <ExposureBar limit={limit} wide />
421 {lines.length > 0 && (
422 <dl className="mt-3 space-y-1 text-xs">
423 {lines.map((line) => (
424 <div key={line.label} className="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-2">
425 <dt className="text-faint">{line.label}</dt>
426 <dd className="text-fg-soft">{line.value}</dd>
427 </div>
428 ))}
429 </dl>
430 )}
431 </div>
432 );
433}
434
435function BilledToSection({
436 billedTo,
437 enterprises,
438 pathname,
439 error,
440}: {
441 billedTo: Enterprise | null;
442 enterprises: { id: string; name: string; members: number }[];
443 pathname: string;
444 error: SectionError;
445}) {
446 return (
447 <Section id="billed-to" title="Billed to" description="Whether the workspace pays for itself, or an enterprise pays for it.">
448 {error && (
449 <div className="mb-4">
450 <Notice tone="error">{error.error}</Notice>
451 </div>
452 )}
453 {billedTo ? (
454 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
455 <p className="text-sm text-muted">
456 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="font-medium text-fg hover:underline hover:underline-offset-4">
457 {billedTo.name}
458 </Link>{" "}
459 pays for it, under its limit and terms.
460 </p>
461 <form method="post" action={`${pathname}#review`}>
462 <input type="hidden" name="intent" value="detach" />
463 <Button type="submit" variant="danger">
464 <LogOut size={14} />
465 Move off
466 </Button>
467 </form>
468 </div>
469 ) : enterprises.length === 0 ? (
470 <p className="text-sm text-muted">
471 Itself. There are no enterprises to move it onto yet;{" "}
472 <Link to="/enterprises/new" className="text-merged hover:underline hover:underline-offset-4">
473 create one
474 </Link>
475 .
476 </p>
477 ) : (
478 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end">
479 <input type="hidden" name="intent" value="attach" />
480 <Field label="Itself. Move onto" className="grow">
481 <Select name="target" required defaultValue={error?.values?.target ?? ""}>
482 <option value="" disabled>
483 Choose an enterprise
484 </option>
485 {enterprises.map((enterprise) => (
486 <option key={enterprise.id} value={enterprise.id}>
487 {enterprise.name} ({enterprise.members} workspace{enterprise.members === 1 ? "" : "s"})
488 </option>
489 ))}
490 </Select>
491 </Field>
492 <Button type="submit" variant="quiet">
493 <Building2 size={14} />
494 Move
495 </Button>
496 </form>
497 )}
498 </Section>
499 );
500}
501
502const PLAN_LABEL: Record<string, string> = { free: "Free", paid: "The g1t plan", internal: "Internal (comped)", enterprise: "Enterprise" };
503
504function gigabytes(bytes: number): string {
505 return `${Math.round((bytes / 1e9) * 100) / 100} GB`;
506}
507
508/** What billing tells every service about the workspace now: plan, caps, pause, trial and what the plan gives it. */
509function PlanSection({ entitlements: e, unavailable }: { entitlements: Entitlements | null; unavailable: string | null }) {
510 if (!e) {
511 return (
512 <Section id="plan" title="Plan and entitlements">
513 <Notice tone="warn">Billing did not answer for entitlements{unavailable ? `: ${unavailable}` : "."}</Notice>
514 </Section>
515 );
516 }
517 const spike = e.spike ? spikeLabel(e.spike.status) : null;
518 const facts: [string, string][] = [
519 ["Compute", e.compute ? "Yes" : e.plan === "free" && e.trialVerified && e.trialMicrosLeft > 0 ? "On the trial" : "No"],
520 ["Card check", e.trialVerified ? "Done" : "Not yet"],
521 ["Trial left", usd(e.trialMicrosLeft)],
522 ["First month", e.firstMonth ? "Yes" : "No"],
523 ["Agents at once", String(e.maxConcurrentAgents)],
524 ["Longest run", e.maxRunMinutes ? `${e.maxRunMinutes} min` : "The guardrails'"],
525 ["Run cap", usd(e.runCapMicros)],
526 ["Issue cap", usd(e.issueCapMicros)],
527 ["Ceiling", e.ceilingMicros >= 1e15 ? "None" : usd(e.ceilingMicros)],
528 ["Not yet paid", usd(e.exposureMicros)],
529 ["Held by reservations", usd(e.heldMicros ?? 0)],
530 ["Prepaid", usd(e.prepaidMicros ?? 0)],
531 ["Included usage", e.includedMicros ? `${usd(e.includedUsedMicros ?? 0)} of ${usd(e.includedMicros)}` : "—"],
532 ["Private storage", `${gigabytes(e.privateStorageBytes)} (${gigabytes(e.freePrivateStorageBytes)} free)`],
533 ["Open-source pool paid", usd(e.ossPaidMicros)],
534 ["Build minutes", `${Math.ceil((e.buildSecondsUsed ?? 0) / 60)}, all metered`],
535 [
536 "Git operations",
537 e.gitOperations != null
538 ? `${e.gitOperations.toLocaleString("en-US")}${e.gitOperationsIncluded ? ` (${e.gitOperationsIncluded.toLocaleString("en-US")} free)` : ""}`
539 : "—",
540 ],
541 ["Audit log", `${e.auditRetentionDays} days${e.auditRetentionCustom ? ", set by staff" : ""}`],
542 ];
543 return (
544 <Section
545 id="plan"
546 title="Plan and entitlements"
547 description="What billing tells every service that starts compute for this workspace, now."
548 actions={
549 <span className="flex flex-wrap gap-1.5">
550 <Badge tone={e.plan === "free" ? "plain" : e.plan === "internal" ? "mint" : "lavender"}>{PLAN_LABEL[e.plan] ?? e.plan}</Badge>
551 {spike && <Badge tone={spike.tone === "plain" ? "plain" : spike.tone}>{spike.label}</Badge>}
552 </span>
553 }
554 >
555 {e.paused && (
556 <div className="mb-4">
557 <Notice tone="error">Compute paused: {e.paused}</Notice>
558 </div>
559 )}
560 {(e.alerts ?? []).length > 0 && (
561 <ul className="mb-4 space-y-1 text-sm">
562 {(e.alerts ?? []).map((alert) => (
563 <li key={`${alert.meter}-${alert.level}`} className={alert.level >= 90 ? "text-warn" : "text-muted"}>
564 {alert.level}% · {alert.message || `${alert.meter}: ${usd(alert.usedMicros)} of ${usd(alert.limitMicros)}`}
565 </li>
566 ))}
567 </ul>
568 )}
569 <dl className="grid gap-x-6 gap-y-1 text-xs sm:grid-cols-2 lg:grid-cols-3">
570 {facts.map(([label, value]) => (
571 <div key={label} className="flex justify-between gap-3 border-b border-line/60 py-1">
572 <dt className="text-faint">{label}</dt>
573 <dd className="tabular text-right text-fg-soft">{value}</dd>
574 </div>
575 ))}
576 </dl>
577 <p className="mt-3 text-xs text-faint">Agents at once, the run and issue caps, and holds are set under Plan, pools and caps below.</p>
578 </Section>
579 );
580}
581
582/** A goodwill credit from the workspace's page; Overages shows the same math for the whole queue. */
583function GoodwillForm({ overage, cap, pathname, error }: { overage: Overage | null; cap: number; pathname: string; error: SectionError }) {
584 const values = error?.values;
585 const quote = overage ? goodwillWarning(null, overage.goodwill, overage.lastGoodwillAt, cap) : null;
586 return (
587 <Section id="goodwill" title="Goodwill credit" description="For usage past what the owners meant. One click, once in 12 months; more needs a reason.">
588 {overage ? (
589 <dl className="mb-4 space-y-1 text-sm">
590 <div className="flex justify-between gap-3">
591 <dt className="text-muted">Over the typical month</dt>
592 <dd className="tabular">{usd(overage.goodwill.overageMicros)}</dd>
593 </div>
594 <div className="flex justify-between gap-3">
595 <dt className="text-muted">g1t's margin, returned</dt>
596 <dd className="tabular text-accent">{usd(overage.goodwill.marginMicros)}</dd>
597 </div>
598 <div className="flex justify-between gap-3">
599 <dt className="text-muted">Real cost absorbed</dt>
600 <dd className={`tabular ${quote?.overCap ? "text-danger" : "text-warn"}`}>{usd(overage.goodwill.absorbedMicros)}</dd>
601 </div>
602 <div className="flex justify-between gap-3 border-t border-line pt-1 font-medium">
603 <dt>One-click credit</dt>
604 <dd className="tabular">{usd(overage.goodwill.creditMicros)}</dd>
605 </div>
606 {!overage.goodwillAvailable && (
607 <p className="pt-1 text-xs text-danger">
608 A goodwill credit was given <When at={overage.lastGoodwillAt} />: another needs a reason.
609 </p>
610 )}
611 </dl>
612 ) : (
613 <p className="mb-4 text-sm text-muted">Not in the Overages queue this month: give an amount and a reason.</p>
614 )}
615 <form method="post" action={`${pathname}#goodwill`} className="space-y-3">
616 <input type="hidden" name="intent" value="goodwill" />
617 {error && <Notice tone="error">{error.error}</Notice>}
618 <div className="grid gap-3 sm:grid-cols-2">
619 <Field label="Amount $" hint={overage ? "Blank: the one-click credit." : "Required here."}>
620 <Input name="amount" inputMode="decimal" defaultValue={values?.amount ?? ""} />
621 </Field>
622 <Field label="Day of the usage" hint="Blank: billing chooses.">
623 <Input type="date" name="day" defaultValue={values?.day ?? ""} />
624 </Field>
625 </div>
626 <Field label="Reason" hint={`Needed past the one-click credit. Past ${usd(cap)} of real cost, use Overages to see it in red first.`}>
627 <Textarea name="reason" rows={2} maxLength={500} defaultValue={values?.reason ?? ""} />
628 </Field>
629 <div className="flex justify-end">
630 <Button type="submit" variant="lavender">
631 Give goodwill credit
632 </Button>
633 </div>
634 </form>
635 </Section>
636 );
637}