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

638 lines27,518 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 {person?.protected && <Badge tone="info">Protected: can never be deleted</Badge>}
212 {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>}
213 <TermsBadge terms={terms} />
214 {terms.kind !== "comped" && <TrustBadge trust={limit.trust} />}
215 <StateBadge state={limit.state} />
216 {sales && sales.stage !== "none" && <StageBadge stage={sales.stage} />}
217 </div>
218 </div>
219 </div>
220 {owners.length > 0 && (
221 <div className="text-xs">
222 <p className="mb-1.5 text-faint">Owners</p>
223 <Owners owners={owners} />
224 </div>
225 )}
226 </div>
227
228 <nav aria-label="On this page" className="mt-6 -mx-4 overflow-x-auto px-4">
229 <ul className="flex gap-1 border-b border-line text-sm">
230 {SECTIONS.map((section) => (
231 <li key={section.id}>
232 <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">
233 {section.label}
234 </a>
235 </li>
236 ))}
237 </ul>
238 </nav>
239
240 <div className="mt-6 space-y-3 empty:hidden">
241 {done && <Notice tone="ok">{done}</Notice>}
242 {error("top") && <Notice tone="error">{error("top")?.error}</Notice>}
243 {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>}
244 {review && <ReviewPanel review={review} pathname={pathname} />}
245 </div>
246
247 {/* People */}
248 <Section id="members" title="Members" description="Everyone in the workspace. Owners manage its members and billing." className="mt-6">
249 {!person ? (
250 <Notice tone="warn">Identity has no record of {slug}; only billing knows it. It may have been deleted.</Notice>
251 ) : person.members.length === 0 ? (
252 <EmptyState title="No members" />
253 ) : (
254 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
255 <table className="w-full min-w-120 text-sm">
256 <thead>
257 <tr className="border-b border-line text-left text-xs text-muted">
258 <th className="px-4 py-2 font-medium sm:pl-5">Member</th>
259 <th className="px-4 py-2 font-medium">Email</th>
260 <th className="px-4 py-2 font-medium">Role</th>
261 <th className="px-4 py-2 font-medium sm:pr-5">Joined</th>
262 </tr>
263 </thead>
264 <tbody>
265 {person.members.map((member) => (
266 <tr key={member.username} className="border-b border-line last:border-0">
267 <td className="px-4 py-2.5 sm:pl-5">
268 <span className="inline-flex items-center gap-2 font-mono">
269 <Avatar name={member.username} size={18} square={false} />
270 <Link to={`/users/${member.username}`} className="hover:underline">
271 {member.username}
272 </Link>
273 </span>
274 </td>
275 <td className="px-4 py-2.5 break-all text-muted">{member.email ?? <span className="text-faint">—</span>}</td>
276 <td className="px-4 py-2.5">{member.role === "owner" ? <Badge tone="lavender">Owner</Badge> : <Badge>Member</Badge>}</td>
277 <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pr-5">
278 <When at={member.joined} />
279 </td>
280 </tr>
281 ))}
282 </tbody>
283 </table>
284 </div>
285 )}
286 </Section>
287
288 {/* Sales */}
289 <div className="mt-6">
290 <SalesSection
291 sales={sales}
292 unavailable={salesError}
293 me={me}
294 pathname={pathname}
295 salesError={error("sales")}
296 noteError={error("note")}
297 done={salesDone}
298 />
299 </div>
300
301 {/* Plan */}
302 <div className="mt-6">
303 <PlanSection entitlements={entitlements} unavailable={entitlementsError} />
304 </div>
305
306 {/* Billing */}
307 <h2 id="billing" className="mt-10 scroll-mt-20 text-lg font-semibold tracking-tight">
308 Billing
309 </h2>
310 <div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
311 <LimitCard limit={limit} terms={terms} billedTo={billedTo} />
312 <Figure label="Charged this month" value={usd(figures.charged)} hint={`Cost to g1t ${usd(figures.cost)}`} />
313 <Figure label="Paid ever" value={usd(figures.paid)} hint={`Margin this month ${usd(figures.charged - figures.cost)}`} />
314 </div>
315
316 <Section
317 title="Last six months"
318 description={billedTo ? `${billedTo.name}'s figures: every workspace it pays for, together.` : "What it was charged, against what its usage cost g1t."}
319 className="mt-6"
320 >
321 <MonthsChart wide months={months} label={`${billedTo ? billedTo.name : name}: charged and cost by month`} />
322 </Section>
323
324 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
325 <div className="space-y-6">
326 <BilledToSection billedTo={billedTo} enterprises={enterprises} pathname={pathname} error={error("billed-to")} />
327 {billedTo ? (
328 <Section id="terms" title="Terms" description={`Charged on ${billedTo.name}'s terms while it pays for this workspace.`}>
329 <div className="flex flex-wrap items-center justify-between gap-3">
330 <TermsBadge terms={terms} />
331 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}#terms`} className="text-sm text-merged hover:underline hover:underline-offset-4">
332 Change them on {billedTo.name}
333 </Link>
334 </div>
335 </Section>
336 ) : (
337 <>
338 <TermsForm terms={terms} pathname={pathname} error={error("terms")} />
339 <AllowancesForm allowances={allowances} comped={terms.kind === "comped"} pathname={pathname} error={error("allowances")} />
340 </>
341 )}
342 <WorkspaceInvoicesSection invoices={invoices} unavailable={invoicesError} />
343 <div id="ledger" className="scroll-mt-20">
344 <LedgerSection
345 ledger={ledger}
346 description={billedTo ? `This workspace's lines among ${billedTo.name}'s latest 100.` : "Recent lines of the statement, newest first."}
347 />
348 </div>
349 </div>
350
351 <div className="space-y-6">
352 <BillingLinkSection link={link} pathname={pathname} error={error("billing-link")} />
353 {!billedTo && terms.kind !== "comped" && (
354 <GoodwillForm overage={overage} cap={forgiveCap} pathname={pathname} error={error("goodwill")} />
355 )}
356 <PaymentForm workspace={slug} pathname={pathname} error={error("payment")} />
357 <CreditForm workspaces={[slug]} pathname={pathname} error={error("credit")} />
358 <div id="audit" className="scroll-mt-20">
359 <AuditSection
360 audit={audit}
361 description={
362 billedTo ? (
363 <>
364 Changes about this workspace in {billedTo.name}'s log.{" "}
365 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="text-merged hover:underline">
366 Full log
367 </Link>
368 </>
369 ) : undefined
370 }
371 />
372 </div>
373 </div>
374 </div>
375 </main>
376 );
377}
378
379/**
380 * The limit, and in words how it is made: trust, the owners' own spend
381 * limit (or the default), the most they may set, and how it grows.
382 */
383function LimitCard({ limit, terms, billedTo }: { limit: Limit; terms: Terms; billedTo: Enterprise | null }) {
384 const lines: { label: string; value: ReactNode }[] = [];
385 if (billedTo) {
386 lines.push({ label: "Limit", value: <>{billedTo.name}'s, shared by every workspace it pays for</> });
387 } else if (terms.kind !== "comped") {
388 lines.push({ label: "From trust", value: <>{usd(limit.trustCeilingMicros)}. {trustAbout(limit.trust)}</> });
389 }
390 if (terms.ceilingMicros != null) lines.push({ label: "Custom limit", value: usd(terms.ceilingMicros) });
391 if (!billedTo && terms.kind !== "comped") {
392 lines.push({
393 label: "Owners' limit",
394 value: limit.defaultSpendLimit ? (
395 <>The default, {usd(limit.spendLimitMicros ?? 200_000_000)} a month: they have not chosen one</>
396 ) : limit.spendLimitMicros == null ? (
397 "None of their own: they use everything available"
398 ) : (
399 <>{usd(limit.spendLimitMicros)} a month, chosen by the owners</>
400 ),
401 });
402 if (limit.availableMicros !== undefined) {
403 lines.push({
404 label: "Available",
405 value:
406 limit.availableMicros == null ? (
407 "No ceiling"
408 ) : (
409 <>Up to {usd(limit.availableMicros)}: the most owners may set; past it, they contact g1t</>
410 ),
411 });
412 }
413 }
414 if (limit.growth) lines.push({ label: "Grows", value: limit.growth });
415
416 return (
417 <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2">
418 <p className="mb-2 text-xs text-muted">
419 Usage this month against the limit{billedTo && <> · shared with every workspace {billedTo.name} pays for</>}
420 </p>
421 <ExposureBar limit={limit} wide />
422 {lines.length > 0 && (
423 <dl className="mt-3 space-y-1 text-xs">
424 {lines.map((line) => (
425 <div key={line.label} className="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-2">
426 <dt className="text-faint">{line.label}</dt>
427 <dd className="text-fg-soft">{line.value}</dd>
428 </div>
429 ))}
430 </dl>
431 )}
432 </div>
433 );
434}
435
436function BilledToSection({
437 billedTo,
438 enterprises,
439 pathname,
440 error,
441}: {
442 billedTo: Enterprise | null;
443 enterprises: { id: string; name: string; members: number }[];
444 pathname: string;
445 error: SectionError;
446}) {
447 return (
448 <Section id="billed-to" title="Billed to" description="Whether the workspace pays for itself, or an enterprise pays for it.">
449 {error && (
450 <div className="mb-4">
451 <Notice tone="error">{error.error}</Notice>
452 </div>
453 )}
454 {billedTo ? (
455 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
456 <p className="text-sm text-muted">
457 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="font-medium text-fg hover:underline hover:underline-offset-4">
458 {billedTo.name}
459 </Link>{" "}
460 pays for it, under its limit and terms.
461 </p>
462 <form method="post" action={`${pathname}#review`}>
463 <input type="hidden" name="intent" value="detach" />
464 <Button type="submit" variant="danger">
465 <LogOut size={14} />
466 Move off
467 </Button>
468 </form>
469 </div>
470 ) : enterprises.length === 0 ? (
471 <p className="text-sm text-muted">
472 Itself. There are no enterprises to move it onto yet;{" "}
473 <Link to="/enterprises/new" className="text-merged hover:underline hover:underline-offset-4">
474 create one
475 </Link>
476 .
477 </p>
478 ) : (
479 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end">
480 <input type="hidden" name="intent" value="attach" />
481 <Field label="Itself. Move onto" className="grow">
482 <Select name="target" required defaultValue={error?.values?.target ?? ""}>
483 <option value="" disabled>
484 Choose an enterprise
485 </option>
486 {enterprises.map((enterprise) => (
487 <option key={enterprise.id} value={enterprise.id}>
488 {enterprise.name} ({enterprise.members} workspace{enterprise.members === 1 ? "" : "s"})
489 </option>
490 ))}
491 </Select>
492 </Field>
493 <Button type="submit" variant="quiet">
494 <Building2 size={14} />
495 Move
496 </Button>
497 </form>
498 )}
499 </Section>
500 );
501}
502
503const PLAN_LABEL: Record<string, string> = { free: "Free", paid: "The g1t plan", internal: "Internal (comped)", enterprise: "Enterprise" };
504
505function gigabytes(bytes: number): string {
506 return `${Math.round((bytes / 1e9) * 100) / 100} GB`;
507}
508
509/** What billing tells every service about the workspace now: plan, caps, pause, trial and what the plan gives it. */
510function PlanSection({ entitlements: e, unavailable }: { entitlements: Entitlements | null; unavailable: string | null }) {
511 if (!e) {
512 return (
513 <Section id="plan" title="Plan and entitlements">
514 <Notice tone="warn">Billing did not answer for entitlements{unavailable ? `: ${unavailable}` : "."}</Notice>
515 </Section>
516 );
517 }
518 const spike = e.spike ? spikeLabel(e.spike.status) : null;
519 const facts: [string, string][] = [
520 ["Compute", e.compute ? "Yes" : e.plan === "free" && e.trialVerified && e.trialMicrosLeft > 0 ? "On the trial" : "No"],
521 ["Card check", e.trialVerified ? "Done" : "Not yet"],
522 ["Trial left", usd(e.trialMicrosLeft)],
523 ["First month", e.firstMonth ? "Yes" : "No"],
524 ["Agents at once", String(e.maxConcurrentAgents)],
525 ["Longest run", e.maxRunMinutes ? `${e.maxRunMinutes} min` : "The guardrails'"],
526 ["Run cap", usd(e.runCapMicros)],
527 ["Issue cap", usd(e.issueCapMicros)],
528 ["Ceiling", e.ceilingMicros >= 1e15 ? "None" : usd(e.ceilingMicros)],
529 ["Not yet paid", usd(e.exposureMicros)],
530 ["Held by reservations", usd(e.heldMicros ?? 0)],
531 ["Prepaid", usd(e.prepaidMicros ?? 0)],
532 ["Included usage", e.includedMicros ? `${usd(e.includedUsedMicros ?? 0)} of ${usd(e.includedMicros)}` : "—"],
533 ["Private storage", `${gigabytes(e.privateStorageBytes)} (${gigabytes(e.freePrivateStorageBytes)} free)`],
534 ["Open-source pool paid", usd(e.ossPaidMicros)],
535 ["Build minutes", `${Math.ceil((e.buildSecondsUsed ?? 0) / 60)}, all metered`],
536 [
537 "Git operations",
538 e.gitOperations != null
539 ? `${e.gitOperations.toLocaleString("en-US")}${e.gitOperationsIncluded ? ` (${e.gitOperationsIncluded.toLocaleString("en-US")} free)` : ""}`
540 : "—",
541 ],
542 ["Audit log", `${e.auditRetentionDays} days${e.auditRetentionCustom ? ", set by staff" : ""}`],
543 ];
544 return (
545 <Section
546 id="plan"
547 title="Plan and entitlements"
548 description="What billing tells every service that starts compute for this workspace, now."
549 actions={
550 <span className="flex flex-wrap gap-1.5">
551 <Badge tone={e.plan === "free" ? "plain" : e.plan === "internal" ? "mint" : "lavender"}>{PLAN_LABEL[e.plan] ?? e.plan}</Badge>
552 {spike && <Badge tone={spike.tone === "plain" ? "plain" : spike.tone}>{spike.label}</Badge>}
553 </span>
554 }
555 >
556 {e.paused && (
557 <div className="mb-4">
558 <Notice tone="error">Compute paused: {e.paused}</Notice>
559 </div>
560 )}
561 {(e.alerts ?? []).length > 0 && (
562 <ul className="mb-4 space-y-1 text-sm">
563 {(e.alerts ?? []).map((alert) => (
564 <li key={`${alert.meter}-${alert.level}`} className={alert.level >= 90 ? "text-warn" : "text-muted"}>
565 {alert.level}% · {alert.message || `${alert.meter}: ${usd(alert.usedMicros)} of ${usd(alert.limitMicros)}`}
566 </li>
567 ))}
568 </ul>
569 )}
570 <dl className="grid gap-x-6 gap-y-1 text-xs sm:grid-cols-2 lg:grid-cols-3">
571 {facts.map(([label, value]) => (
572 <div key={label} className="flex justify-between gap-3 border-b border-line/60 py-1">
573 <dt className="text-faint">{label}</dt>
574 <dd className="tabular text-right text-fg-soft">{value}</dd>
575 </div>
576 ))}
577 </dl>
578 <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>
579 </Section>
580 );
581}
582
583/** A goodwill credit from the workspace's page; Overages shows the same math for the whole queue. */
584function GoodwillForm({ overage, cap, pathname, error }: { overage: Overage | null; cap: number; pathname: string; error: SectionError }) {
585 const values = error?.values;
586 const quote = overage ? goodwillWarning(null, overage.goodwill, overage.lastGoodwillAt, cap) : null;
587 return (
588 <Section id="goodwill" title="Goodwill credit" description="For usage past what the owners meant. One click, once in 12 months; more needs a reason.">
589 {overage ? (
590 <dl className="mb-4 space-y-1 text-sm">
591 <div className="flex justify-between gap-3">
592 <dt className="text-muted">Over the typical month</dt>
593 <dd className="tabular">{usd(overage.goodwill.overageMicros)}</dd>
594 </div>
595 <div className="flex justify-between gap-3">
596 <dt className="text-muted">g1t's margin, returned</dt>
597 <dd className="tabular text-accent">{usd(overage.goodwill.marginMicros)}</dd>
598 </div>
599 <div className="flex justify-between gap-3">
600 <dt className="text-muted">Real cost absorbed</dt>
601 <dd className={`tabular ${quote?.overCap ? "text-danger" : "text-warn"}`}>{usd(overage.goodwill.absorbedMicros)}</dd>
602 </div>
603 <div className="flex justify-between gap-3 border-t border-line pt-1 font-medium">
604 <dt>One-click credit</dt>
605 <dd className="tabular">{usd(overage.goodwill.creditMicros)}</dd>
606 </div>
607 {!overage.goodwillAvailable && (
608 <p className="pt-1 text-xs text-danger">
609 A goodwill credit was given <When at={overage.lastGoodwillAt} />: another needs a reason.
610 </p>
611 )}
612 </dl>
613 ) : (
614 <p className="mb-4 text-sm text-muted">Not in the Overages queue this month: give an amount and a reason.</p>
615 )}
616 <form method="post" action={`${pathname}#goodwill`} className="space-y-3">
617 <input type="hidden" name="intent" value="goodwill" />
618 {error && <Notice tone="error">{error.error}</Notice>}
619 <div className="grid gap-3 sm:grid-cols-2">
620 <Field label="Amount $" hint={overage ? "Blank: the one-click credit." : "Required here."}>
621 <Input name="amount" inputMode="decimal" defaultValue={values?.amount ?? ""} />
622 </Field>
623 <Field label="Day of the usage" hint="Blank: billing chooses.">
624 <Input type="date" name="day" defaultValue={values?.day ?? ""} />
625 </Field>
626 </div>
627 <Field label="Reason" hint={`Needed past the one-click credit. Past ${usd(cap)} of real cost, use Overages to see it in red first.`}>
628 <Textarea name="reason" rows={2} maxLength={500} defaultValue={values?.reason ?? ""} />
629 </Field>
630 <div className="flex justify-end">
631 <Button type="submit" variant="lavender">
632 Give goodwill credit
633 </Button>
634 </div>
635 </form>
636 </Section>
637 );
638}