g1t/apps/sudo/app/routes/workspace.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.
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 1 | import { ArrowLeft, Building2, LogOut } from "lucide-react"; |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 2 | import type { ReactNode } from "react"; |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 3 | import { data, Link, useLocation } from "react-router"; |
| 4 | ||
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 5 | import { type AccountSummary, type AdminAction, type Limit, type Terms, httpStatus } from "@g1t/contracts"; |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 6 | |
| 7 | import type { Route } from "./+types/workspace"; | |
| 8 | import { | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 9 | AllowancesForm, |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 10 | AuditSection, |
| 11 | BillingLinkSection, | |
| 12 | CreditForm, | |
| 13 | Figure, | |
| 14 | LedgerSection, | |
| 15 | Owners, | |
| 16 | ReviewPanel, | |
| 17 | TermsForm, | |
| 18 | } from "~/components/billing"; | |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 19 | import { MonthsChart } from "~/components/charts"; |
| 20 | import { SalesSection, StageBadge, WorkspaceInvoicesSection } from "~/components/sales"; | |
| 21 | import { Avatar, Badge, Button, EmptyState, ExposureBar, Field, Notice, Section, Select, StateBadge, TermsBadge, TrustBadge, When, trustAbout } from "~/components/ui"; | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 22 | import { type Subject, billingAction } from "~/lib/billing-actions.server"; |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 23 | import { parseSlug, text } from "~/lib/forms"; |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 24 | import { usd } from "~/lib/money"; |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 25 | import { type ActionData, DONE, type SectionError, doneKey } from "~/lib/review"; |
| 26 | import { SALES_INTENTS, salesAction } from "~/lib/sales-actions.server"; | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 27 | import { admin, identity } from "~/lib/services.server"; |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 28 | import { settle } from "~/lib/settle"; |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 29 | import { requireStaff } from "~/lib/staff"; |
| 30 | import type { Enterprise } from "~/lib/workspaces"; | |
| 31 | ||
| 32 | export const meta: Route.MetaFunction = ({ loaderData }) => [ | |
| 33 | { title: `${loaderData?.name ?? "Workspace"} · sudo` }, | |
| 34 | { name: "robots", content: "noindex, nofollow" }, | |
| 35 | ]; | |
| 36 | ||
| 37 | /** Whether an audit line is about `slug`, in an enterprise's log. */ | |
| 38 | function mentions(action: AdminAction, slug: string): boolean { | |
| 39 | // A slug is letters, digits and hyphens, none special in a pattern. | |
| 40 | return new RegExp(`(^|[^a-z0-9-])${slug}($|[^a-z0-9-])`).test(action.detail.toLowerCase()); | |
| 41 | } | |
| 42 | ||
| 43 | async function load(raw: string) { | |
| 44 | const parsed = parseSlug(raw); | |
| 45 | if (!parsed.ok) throw data("That is not a workspace slug.", { status: 404 }); | |
| 46 | const slug = parsed.value; | |
| 47 | const [person, result] = await Promise.all([identity.workspace(slug), admin.account(slug)]); | |
| 48 | if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) }); | |
| 49 | const detail = result.value; | |
| 50 | const { account } = detail.summary; | |
| 51 | const billedTo: Enterprise | null = account.kind === "enterprise" ? { id: account.id, name: account.name } : null; | |
| 52 | const known = person != null || billedTo != null || account.createdAt !== "" || detail.ledger.length > 0 || detail.audit.length > 0; | |
| 53 | if (!known) throw data(`There is no workspace called ${slug}.`, { status: 404 }); | |
| 54 | const subject: Subject = { kind: "workspace", slug, accountId: account.id, terms: account.terms, billedTo }; | |
| 55 | return { slug, person, detail, billedTo, subject }; | |
| 56 | } | |
| 57 | ||
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 58 | /** Sales and invoices are newer billing methods; a page still opens without them. */ |
| 59 | const SALES_DONE = new Set(["sales", "note"]); | |
| 60 | ||
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 61 | export async function loader({ request, params, context }: Route.LoaderArgs) { |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 62 | const staff = requireStaff(context); |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 63 | const { slug, person, detail, billedTo, subject } = await load(params.slug); |
| 64 | const { summary } = detail; | |
| 65 | // On an enterprise, the limit is the enterprise's and the figures are | |
| 66 | // this workspace's share of its bill. | |
| 67 | const limit = (billedTo && detail.workspaces.find((member) => member.workspace === slug)) || summary.limit; | |
| 68 | const share = billedTo ? summary.byWorkspace?.find((row) => row.workspace === slug) : null; | |
| 69 | const figures = billedTo | |
| 70 | ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 } | |
| 71 | : { charged: summary.chargedMicros, cost: summary.costMicros, paid: summary.paidMicros }; | |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 72 | const [enterprises, sales, invoices] = await Promise.all([ |
| 73 | billedTo | |
| 74 | ? Promise.resolve([]) | |
| 75 | : admin | |
| 76 | .accounts() | |
| 77 | .then((rows: AccountSummary[]) => | |
| 78 | rows | |
| 79 | .filter((row) => row.account.kind === "enterprise") | |
| 80 | .map((row) => ({ id: row.account.id, name: row.account.name, members: row.account.workspaces.length })), | |
| 81 | ), | |
| 82 | settle(admin.sales(slug)), | |
| 83 | settle(admin.workspaceInvoices(slug)), | |
| 84 | ]); | |
| 85 | const done = doneKey(request.url); | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 86 | return { |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 87 | me: staff.email, |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 88 | slug, |
| 89 | name: person?.name ?? slug, | |
| 90 | person, | |
| 91 | billedTo, | |
| 92 | terms: subject.terms, | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 93 | allowances: summary.account.allowances, |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 94 | limit, |
| 95 | figures, | |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 96 | months: summary.months ?? [], |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 97 | enterprises, |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 98 | sales: sales.ok ? sales.value : null, |
| 99 | salesError: sales.ok ? null : sales.error, | |
| 100 | invoices: invoices.ok ? invoices.value : [], | |
| 101 | invoicesError: invoices.ok ? null : invoices.error, | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 102 | ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger, |
| 103 | audit: billedTo ? detail.audit.filter((entry) => mentions(entry, slug)) : detail.audit, | |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 104 | done: done && !SALES_DONE.has(done) ? DONE[done] : null, |
| 105 | salesDone: done && SALES_DONE.has(done) ? DONE[done] : null, | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 106 | }; |
| 107 | } | |
| 108 | ||
| 109 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 110 | const staff = requireStaff(context); | |
| 111 | // What is acted on comes from billing and identity, not from the form. | |
| 112 | const { slug, subject } = await load(params.slug); | |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 113 | const path = `/workspaces/${encodeURIComponent(slug)}`; |
| 114 | const form = await request.clone().formData(); | |
| 115 | if (SALES_INTENTS.has(text(form, "intent"))) return salesAction(form, staff, slug, path); | |
| 116 | return billingAction(request, staff, subject, path); | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 117 | } |
| 118 | ||
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 119 | const SECTIONS = [ |
| 120 | { id: "members", label: "Members" }, | |
| 121 | { id: "sales", label: "Sales" }, | |
| 122 | { id: "billing", label: "Billing" }, | |
| 123 | { id: "invoices", label: "Invoices" }, | |
| 124 | { id: "ledger", label: "Ledger" }, | |
| 125 | { id: "audit", label: "Audit log" }, | |
| 126 | ]; | |
| 127 | ||
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 128 | export default function Workspace({ loaderData, actionData }: Route.ComponentProps) { |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 129 | const { me, slug, name, person, billedTo, terms, allowances, limit, figures, months, enterprises, sales, salesError, invoices, invoicesError, ledger, audit, done, salesDone } = |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 130 | loaderData; |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 131 | const { pathname } = useLocation(); |
| 132 | const result = actionData as ActionData | undefined; | |
| 133 | const review = result && "review" in result ? result.review : null; | |
| 134 | const link = result && "link" in result ? result.link : null; | |
| 135 | const error = (section: string): SectionError => (result && "error" in result && result.section === section ? result : null); | |
| 136 | const owners = person?.members.filter((member) => member.role === "owner") ?? []; | |
| 137 | ||
| 138 | return ( | |
| 139 | <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10"> | |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 140 | <Link to="/workspaces" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 141 | <ArrowLeft size={14} /> |
| 142 | Workspaces | |
| 143 | </Link> | |
| 144 | ||
| 145 | {/* Header */} | |
| 146 | <div className="mt-4 flex flex-wrap items-start justify-between gap-4"> | |
| 147 | <div className="flex min-w-0 items-start gap-3"> | |
| 148 | <Avatar name={slug} size={40} /> | |
| 149 | <div className="min-w-0"> | |
| 150 | <h1 className="truncate text-2xl font-semibold tracking-tight">{name}</h1> | |
| 151 | <p className="mt-0.5 text-xs text-faint"> | |
| 152 | <span className="font-mono">{slug}</span> | |
| 153 | {person && ( | |
| 154 | <> | |
| 155 | {" · "}created <When at={person.createdAt} /> | |
| 156 | </> | |
| 157 | )} | |
| 158 | </p> | |
| 159 | {person?.description && <p className="mt-1 text-sm text-muted">{person.description}</p>} | |
| 160 | <div className="mt-2 flex flex-wrap gap-1.5"> | |
| 161 | {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>} | |
| 162 | <TermsBadge terms={terms} /> | |
| 163 | {terms.kind !== "comped" && <TrustBadge trust={limit.trust} />} | |
| 164 | <StateBadge state={limit.state} /> | |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 165 | {sales && sales.stage !== "none" && <StageBadge stage={sales.stage} />} |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 166 | </div> |
| 167 | </div> | |
| 168 | </div> | |
| 169 | {owners.length > 0 && ( | |
| 170 | <div className="text-xs"> | |
| 171 | <p className="mb-1.5 text-faint">Owners</p> | |
| 172 | <Owners owners={owners} /> | |
| 173 | </div> | |
| 174 | )} | |
| 175 | </div> | |
| 176 | ||
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 177 | <nav aria-label="On this page" className="mt-6 -mx-4 overflow-x-auto px-4"> |
| 178 | <ul className="flex gap-1 border-b border-line text-sm"> | |
| 179 | {SECTIONS.map((section) => ( | |
| 180 | <li key={section.id}> | |
| 181 | <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"> | |
| 182 | {section.label} | |
| 183 | </a> | |
| 184 | </li> | |
| 185 | ))} | |
| 186 | </ul> | |
| 187 | </nav> | |
| 188 | ||
| 189 | <div className="mt-6 space-y-3 empty:hidden"> | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 190 | {done && <Notice tone="ok">{done}</Notice>} |
| 191 | {error("top") && <Notice tone="error">{error("top")?.error}</Notice>} | |
| 192 | {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>} | |
| 193 | {review && <ReviewPanel review={review} pathname={pathname} />} | |
| 194 | </div> | |
| 195 | ||
| 196 | {/* People */} | |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 197 | <Section id="members" title="Members" description="Everyone in the workspace. Owners manage its members and billing." className="mt-6"> |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 198 | {!person ? ( |
| 199 | <Notice tone="warn">Identity has no record of {slug}; only billing knows it. It may have been deleted.</Notice> | |
| 200 | ) : person.members.length === 0 ? ( | |
| 201 | <EmptyState title="No members" /> | |
| 202 | ) : ( | |
| 203 | <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5"> | |
| 204 | <table className="w-full min-w-120 text-sm"> | |
| 205 | <thead> | |
| 206 | <tr className="border-b border-line text-left text-xs text-muted"> | |
| 207 | <th className="px-4 py-2 font-medium sm:pl-5">Member</th> | |
| 208 | <th className="px-4 py-2 font-medium">Email</th> | |
| 209 | <th className="px-4 py-2 font-medium">Role</th> | |
| 210 | <th className="px-4 py-2 font-medium sm:pr-5">Joined</th> | |
| 211 | </tr> | |
| 212 | </thead> | |
| 213 | <tbody> | |
| 214 | {person.members.map((member) => ( | |
| 215 | <tr key={member.username} className="border-b border-line last:border-0"> | |
| 216 | <td className="px-4 py-2.5 sm:pl-5"> | |
| 217 | <span className="inline-flex items-center gap-2 font-mono"> | |
| 218 | <Avatar name={member.username} size={18} square={false} /> | |
| 219 | {member.username} | |
| 220 | </span> | |
| 221 | </td> | |
| 222 | <td className="px-4 py-2.5 break-all text-muted">{member.email ?? <span className="text-faint">—</span>}</td> | |
| 223 | <td className="px-4 py-2.5">{member.role === "owner" ? <Badge tone="lavender">Owner</Badge> : <Badge>Member</Badge>}</td> | |
| 224 | <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pr-5"> | |
| 225 | <When at={member.joined} /> | |
| 226 | </td> | |
| 227 | </tr> | |
| 228 | ))} | |
| 229 | </tbody> | |
| 230 | </table> | |
| 231 | </div> | |
| 232 | )} | |
| 233 | </Section> | |
| 234 | ||
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 235 | {/* Sales */} |
| 236 | <div className="mt-6"> | |
| 237 | <SalesSection | |
| 238 | sales={sales} | |
| 239 | unavailable={salesError} | |
| 240 | me={me} | |
| 241 | pathname={pathname} | |
| 242 | salesError={error("sales")} | |
| 243 | noteError={error("note")} | |
| 244 | done={salesDone} | |
| 245 | /> | |
| 246 | </div> | |
| 247 | ||
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 248 | {/* Billing */} |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 249 | <h2 id="billing" className="mt-10 scroll-mt-20 text-lg font-semibold tracking-tight"> |
| 250 | Billing | |
| 251 | </h2> | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 252 | <div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 253 | <LimitCard limit={limit} terms={terms} billedTo={billedTo} /> |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 254 | <Figure label="Charged this month" value={usd(figures.charged)} hint={`Cost to g1t ${usd(figures.cost)}`} /> |
| 255 | <Figure label="Paid ever" value={usd(figures.paid)} hint={`Margin this month ${usd(figures.charged - figures.cost)}`} /> | |
| 256 | </div> | |
| 257 | ||
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 258 | <Section |
| 259 | title="Last six months" | |
| 260 | description={billedTo ? `${billedTo.name}'s figures: every workspace it pays for, together.` : "What it was charged, against what its usage cost g1t."} | |
| 261 | className="mt-6" | |
| 262 | > | |
| 263 | <MonthsChart wide months={months} label={`${billedTo ? billedTo.name : name}: charged and cost by month`} /> | |
| 264 | </Section> | |
| 265 | ||
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 266 | <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"> |
| 267 | <div className="space-y-6"> | |
| 268 | <BilledToSection billedTo={billedTo} enterprises={enterprises} pathname={pathname} error={error("billed-to")} /> | |
| 269 | {billedTo ? ( | |
| 270 | <Section id="terms" title="Terms" description={`Charged on ${billedTo.name}'s terms while it pays for this workspace.`}> | |
| 271 | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 272 | <TermsBadge terms={terms} /> | |
| 273 | <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}#terms`} className="text-sm text-merged hover:underline hover:underline-offset-4"> | |
| 274 | Change them on {billedTo.name} | |
| 275 | </Link> | |
| 276 | </div> | |
| 277 | </Section> | |
| 278 | ) : ( | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 279 | <> |
| 280 | <TermsForm terms={terms} pathname={pathname} error={error("terms")} /> | |
| 281 | <AllowancesForm allowances={allowances} comped={terms.kind === "comped"} pathname={pathname} error={error("allowances")} /> | |
| 282 | </> | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 283 | )} |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 284 | <WorkspaceInvoicesSection invoices={invoices} unavailable={invoicesError} /> |
| 285 | <div id="ledger" className="scroll-mt-20"> | |
| 286 | <LedgerSection | |
| 287 | ledger={ledger} | |
| 288 | description={billedTo ? `This workspace's lines among ${billedTo.name}'s latest 100.` : "Recent lines of the statement, newest first."} | |
| 289 | /> | |
| 290 | </div> | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 291 | </div> |
| 292 | ||
| 293 | <div className="space-y-6"> | |
| 294 | <BillingLinkSection link={link} pathname={pathname} error={error("billing-link")} /> | |
| 295 | <CreditForm workspaces={[slug]} pathname={pathname} error={error("credit")} /> | |
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 296 | <div id="audit" className="scroll-mt-20"> |
| 297 | <AuditSection | |
| 298 | audit={audit} | |
| 299 | description={ | |
| 300 | billedTo ? ( | |
| 301 | <> | |
| 302 | Changes about this workspace in {billedTo.name}'s log.{" "} | |
| 303 | <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="text-merged hover:underline"> | |
| 304 | Full log | |
| 305 | </Link> | |
| 306 | </> | |
| 307 | ) : undefined | |
| 308 | } | |
| 309 | /> | |
| 310 | </div> | |
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 311 | </div> |
| 312 | </div> | |
| 313 | </main> | |
| 314 | ); | |
| 315 | } | |
| 316 | ||
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 317 | /** |
| 318 | * The limit, and in words how it is made: trust, the owners' own spend | |
| 319 | * limit (or the default), the most they may set, and how it grows. | |
| 320 | */ | |
| 321 | function LimitCard({ limit, terms, billedTo }: { limit: Limit; terms: Terms; billedTo: Enterprise | null }) { | |
| 322 | const lines: { label: string; value: ReactNode }[] = []; | |
| 323 | if (billedTo) { | |
| 324 | lines.push({ label: "Limit", value: <>{billedTo.name}'s, shared by every workspace it pays for</> }); | |
| 325 | } else if (terms.kind !== "comped") { | |
| 326 | lines.push({ label: "From trust", value: <>{usd(limit.trustCeilingMicros)}. {trustAbout(limit.trust)}</> }); | |
| 327 | } | |
| 328 | if (terms.ceilingMicros != null) lines.push({ label: "Custom limit", value: usd(terms.ceilingMicros) }); | |
| 329 | if (!billedTo && terms.kind !== "comped") { | |
| 330 | lines.push({ | |
| 331 | label: "Owners' limit", | |
| 332 | value: limit.defaultSpendLimit ? ( | |
| 333 | <>The default, {usd(limit.spendLimitMicros ?? 200_000_000)} a month: they have not chosen one</> | |
| 334 | ) : limit.spendLimitMicros == null ? ( | |
| 335 | "None of their own: they use everything available" | |
| 336 | ) : ( | |
| 337 | <>{usd(limit.spendLimitMicros)} a month, chosen by the owners</> | |
| 338 | ), | |
| 339 | }); | |
| 340 | if (limit.availableMicros !== undefined) { | |
| 341 | lines.push({ | |
| 342 | label: "Available", | |
| 343 | value: | |
| 344 | limit.availableMicros == null ? ( | |
| 345 | "No ceiling" | |
| 346 | ) : ( | |
| 347 | <>Up to {usd(limit.availableMicros)}: the most owners may set; past it, they contact g1t</> | |
| 348 | ), | |
| 349 | }); | |
| 350 | } | |
| 351 | } | |
| 352 | if (limit.growth) lines.push({ label: "Grows", value: limit.growth }); | |
| 353 | ||
| 354 | return ( | |
| 355 | <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2"> | |
| 356 | <p className="mb-2 text-xs text-muted"> | |
| 357 | Usage this month against the limit{billedTo && <> · shared with every workspace {billedTo.name} pays for</>} | |
| 358 | </p> | |
| 359 | <ExposureBar limit={limit} wide /> | |
| 360 | {lines.length > 0 && ( | |
| 361 | <dl className="mt-3 space-y-1 text-xs"> | |
| 362 | {lines.map((line) => ( | |
| 363 | <div key={line.label} className="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-2"> | |
| 364 | <dt className="text-faint">{line.label}</dt> | |
| 365 | <dd className="text-fg-soft">{line.value}</dd> | |
| 366 | </div> | |
| 367 | ))} | |
| 368 | </dl> | |
| 369 | )} | |
| 370 | </div> | |
| 371 | ); | |
| 372 | } | |
| 373 | ||
| Billing on Stripe's pages, month-end charges, warnings; sudo by workspace | 374 | function BilledToSection({ |
| 375 | billedTo, | |
| 376 | enterprises, | |
| 377 | pathname, | |
| 378 | error, | |
| 379 | }: { | |
| 380 | billedTo: Enterprise | null; | |
| 381 | enterprises: { id: string; name: string; members: number }[]; | |
| 382 | pathname: string; | |
| 383 | error: SectionError; | |
| 384 | }) { | |
| 385 | return ( | |
| 386 | <Section id="billed-to" title="Billed to" description="Whether the workspace pays for itself, or an enterprise pays for it."> | |
| 387 | {error && ( | |
| 388 | <div className="mb-4"> | |
| 389 | <Notice tone="error">{error.error}</Notice> | |
| 390 | </div> | |
| 391 | )} | |
| 392 | {billedTo ? ( | |
| 393 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | |
| 394 | <p className="text-sm text-muted"> | |
| 395 | <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="font-medium text-fg hover:underline hover:underline-offset-4"> | |
| 396 | {billedTo.name} | |
| 397 | </Link>{" "} | |
| 398 | pays for it, under its limit and terms. | |
| 399 | </p> | |
| 400 | <form method="post" action={`${pathname}#review`}> | |
| 401 | <input type="hidden" name="intent" value="detach" /> | |
| 402 | <Button type="submit" variant="danger"> | |
| 403 | <LogOut size={14} /> | |
| 404 | Move off | |
| 405 | </Button> | |
| 406 | </form> | |
| 407 | </div> | |
| 408 | ) : enterprises.length === 0 ? ( | |
| 409 | <p className="text-sm text-muted"> | |
| 410 | Itself. There are no enterprises to move it onto yet;{" "} | |
| 411 | <Link to="/enterprises/new" className="text-merged hover:underline hover:underline-offset-4"> | |
| 412 | create one | |
| 413 | </Link> | |
| 414 | . | |
| 415 | </p> | |
| 416 | ) : ( | |
| 417 | <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end"> | |
| 418 | <input type="hidden" name="intent" value="attach" /> | |
| 419 | <Field label="Itself. Move onto" className="grow"> | |
| 420 | <Select name="target" required defaultValue={error?.values?.target ?? ""}> | |
| 421 | <option value="" disabled> | |
| 422 | Choose an enterprise | |
| 423 | </option> | |
| 424 | {enterprises.map((enterprise) => ( | |
| 425 | <option key={enterprise.id} value={enterprise.id}> | |
| 426 | {enterprise.name} ({enterprise.members} workspace{enterprise.members === 1 ? "" : "s"}) | |
| 427 | </option> | |
| 428 | ))} | |
| 429 | </Select> | |
| 430 | </Field> | |
| 431 | <Button type="submit" variant="quiet"> | |
| 432 | <Building2 size={14} /> | |
| 433 | Move | |
| 434 | </Button> | |
| 435 | </form> | |
| 436 | )} | |
| 437 | </Section> | |
| 438 | ); | |
| 439 | } |