pr_01m47d15m3e54sn21z27rpy5n9/apps/sudo/app/routes/enterprise.tsx

369 lines16,681 bytesCodeBlame
1import { ArrowLeft, ExternalLink, Mail, Plus, Send, Trash2 } from "lucide-react";
2import { data, Link, redirect, useLocation } from "react-router";
3
4import { type AdminOwner, type EnterpriseInvoice, type Limit, httpStatus } from "@g1t/contracts";
5
6import type { Route } from "./+types/enterprise";
7import { AuditSection, CreditForm, Figure, LedgerSection, ReviewPanel, TermsForm } from "~/components/billing";
8import { Avatar, Badge, Button, EmptyState, ExposureBar, Field, Input, Notice, Section, StateBadge, TermsBadge, TrustBadge, When } from "~/components/ui";
9import { type Subject, billingAction } from "~/lib/billing-actions.server";
10import { usd } from "~/lib/money";
11import { type ActionData, type SectionError, doneMessage } from "~/lib/review";
12import { admin, identity } from "~/lib/services.server";
13import { requireStaff } from "~/lib/staff";
14import { legacyAccountPath } from "~/lib/workspaces";
15
16export const meta: Route.MetaFunction = ({ loaderData }) => [
17 { title: `${loaderData?.detail.summary.account.name ?? "Enterprise"} · sudo` },
18 { name: "robots", content: "noindex, nofollow" },
19];
20
21const ENTERPRISE_ID = /^ent_[a-z0-9_-]{1,80}$/;
22
23async function load(raw: string) {
24 const id = raw.trim().toLowerCase();
25 if (!ENTERPRISE_ID.test(id)) {
26 // A workspace's id or slug: its own page.
27 const path = legacyAccountPath(id);
28 if (path) throw redirect(path);
29 throw data("That is not an enterprise.", { status: 404 });
30 }
31 const result = await admin.account(id);
32 if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) });
33 const detail = result.value;
34 const { account } = detail.summary;
35 if (account.kind !== "enterprise") throw data("That is not an enterprise.", { status: 404 });
36 const subject: Subject = {
37 kind: "enterprise",
38 accountId: account.id,
39 name: account.name,
40 terms: account.terms,
41 workspaces: account.workspaces,
42 billingEmail: account.billingEmail ?? null,
43 };
44 return { detail, subject };
45}
46
47export async function loader({ request, params, context }: Route.LoaderArgs) {
48 requireStaff(context);
49 const { detail } = await load(params.id);
50 const slugs = detail.summary.account.workspaces;
51 const listed = await identity.workspaces();
52 const people = new Map(listed.map((workspace) => [workspace.slug, { name: workspace.name, owners: workspace.owners }]));
53 // Members older than identity's list: look them up one by one.
54 const missing = slugs.filter((slug) => !people.has(slug)).slice(0, 25);
55 for (const found of await Promise.all(missing.map((slug) => identity.workspace(slug)))) {
56 if (found) {
57 people.set(found.slug, {
58 name: found.name,
59 owners: found.members.filter((member) => member.role === "owner").map(({ username, email }) => ({ username, email })),
60 });
61 }
62 }
63 const limits = new Map(detail.workspaces.map((limit) => [limit.workspace, limit]));
64 const shares = new Map((detail.summary.byWorkspace ?? []).map((share) => [share.workspace, share]));
65 const members = slugs.map((slug) => ({
66 slug,
67 name: people.get(slug)?.name ?? null,
68 owners: people.get(slug)?.owners ?? ([] as AdminOwner[]),
69 limit: limits.get(slug) ?? null,
70 chargedMicros: shares.get(slug)?.chargedMicros ?? 0,
71 }));
72 return { detail, members, done: doneMessage(request.url) };
73}
74
75export async function action({ request, params, context }: Route.ActionArgs) {
76 const staff = requireStaff(context);
77 // What is acted on comes from billing, not from the form.
78 const { subject } = await load(params.id);
79 return billingAction(request, staff, subject, `/enterprises/${encodeURIComponent(subject.accountId)}`);
80}
81
82type Member = Route.ComponentProps["loaderData"]["members"][number];
83
84export default function Enterprise({ loaderData, actionData }: Route.ComponentProps) {
85 const { detail, members, done } = loaderData;
86 const { summary } = detail;
87 const { account, limit } = summary;
88 const { pathname } = useLocation();
89 const result = actionData as ActionData | undefined;
90 const review = result && "review" in result ? result.review : null;
91 const sent = result && "invoice" in result ? result.invoice : null;
92 const error = (section: string): SectionError => (result && "error" in result && result.section === section ? result : null);
93
94 return (
95 <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
96 <Link to="/enterprises" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
97 <ArrowLeft size={14} />
98 Enterprises
99 </Link>
100
101 <div className="mt-4 flex flex-wrap items-start justify-between gap-4">
102 <div className="flex min-w-0 items-start gap-3">
103 <Avatar name={account.name} size={40} />
104 <div className="min-w-0">
105 <h1 className="truncate text-2xl font-semibold tracking-tight">{account.name}</h1>
106 <p className="mt-0.5 text-xs text-faint">
107 Enterprise since <When at={account.createdAt} /> · pays for {members.length} workspace{members.length === 1 ? "" : "s"}
108 </p>
109 <div className="mt-2 flex flex-wrap gap-1.5">
110 <Badge tone="lavender">Enterprise</Badge>
111 <TermsBadge terms={account.terms} />
112 {account.terms.kind !== "comped" && <TrustBadge trust={limit.trust} />}
113 <StateBadge state={limit.state} />
114 </div>
115 </div>
116 </div>
117 <p className="text-xs text-faint">
118 Billing account id <span className="font-mono">{account.id}</span>
119 </p>
120 </div>
121
122 <div className="mt-6 space-y-3">
123 {done && <Notice tone="ok">{done}</Notice>}
124 {error("top") && <Notice tone="error">{error("top")?.error}</Notice>}
125 {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>}
126 {review && <ReviewPanel review={review} pathname={pathname} />}
127 </div>
128
129 <div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
130 <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2">
131 <p className="mb-2 text-xs text-muted">Usage this month against the limit, all workspaces together</p>
132 <ExposureBar limit={limit} wide />
133 <p className="mt-2 text-xs text-faint">
134 Limit from trust {usd(limit.trustCeilingMicros)}
135 {account.terms.ceilingMicros != null && <> · custom limit {usd(account.terms.ceilingMicros)}</>}
136 </p>
137 </div>
138 <Figure label="Charged this month" value={usd(summary.chargedMicros)} hint={`Cost to g1t ${usd(summary.costMicros)}`} />
139 <Figure label="Paid ever" value={usd(summary.paidMicros)} hint={`Margin this month ${usd(summary.chargedMicros - summary.costMicros)}`} />
140 </div>
141
142 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
143 <div className="space-y-6">
144 <MembersSection members={members} pathname={pathname} error={error("members")} />
145 <InvoicesSection
146 email={account.billingEmail ?? null}
147 invoices={account.invoices ?? []}
148 sent={sent}
149 pathname={pathname}
150 error={error("invoices")}
151 />
152 <TermsForm terms={account.terms} pathname={pathname} error={error("terms")} />
153 <LedgerSection ledger={detail.ledger} showWorkspace description="Recent lines for every workspace it pays for, newest first." />
154 </div>
155 <div className="space-y-6">
156 <CreditForm workspaces={account.workspaces} pathname={pathname} error={error("credit")} />
157 <AuditSection audit={detail.audit} />
158 </div>
159 </div>
160 </main>
161 );
162}
163
164function MembersSection({ members, pathname, error }: { members: Member[]; pathname: string; error: SectionError }) {
165 return (
166 <Section id="members" title="Workspaces" description="Billed together: one bill, one limit, one set of terms.">
167 {error && (
168 <div className="mb-4">
169 <Notice tone="error">{error.error}</Notice>
170 </div>
171 )}
172 {members.length === 0 ? (
173 <EmptyState title="No workspaces yet">Add one below to bill it to this enterprise.</EmptyState>
174 ) : (
175 <ul className="divide-y divide-line rounded-md border border-line">
176 {members.map((member) => (
177 <li key={member.slug} className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center">
178 <div className="flex min-w-0 grow items-start gap-2.5">
179 <Avatar name={member.slug} size={22} />
180 <div className="min-w-0">
181 <Link to={`/workspaces/${encodeURIComponent(member.slug)}`} className="font-medium hover:underline hover:underline-offset-4">
182 {member.name ?? member.slug}
183 </Link>
184 <p className="truncate text-xs text-faint">
185 <span className="font-mono">{member.slug}</span>
186 {member.owners.length > 0 && (
187 <>
188 {" · "}
189 {member.owners.map((owner, index) => (
190 <span key={owner.username} title={owner.email ?? "No email"}>
191 {index > 0 && ", "}
192 {owner.username}
193 </span>
194 ))}
195 </>
196 )}
197 </p>
198 {member.limit?.message && <p className="mt-1 text-xs text-muted">{member.limit.message}</p>}
199 </div>
200 </div>
201 <div className="flex items-center justify-between gap-3 sm:w-56 sm:justify-end">
202 <p className="tabular text-right text-xs text-muted">
203 Charged <span className="text-fg-soft">{usd(member.chargedMicros)}</span>
204 {member.limit && <MemberState limit={member.limit} />}
205 </p>
206 <DetachButton workspace={member.slug} pathname={pathname} />
207 </div>
208 </li>
209 ))}
210 </ul>
211 )}
212 <form method="post" action={`${pathname}#review`} className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-end">
213 <input type="hidden" name="intent" value="attach" />
214 <Field label="Add a workspace" className="grow">
215 <Input name="workspace" required placeholder="workspace-slug" defaultValue={error?.values?.workspace ?? ""} className="font-mono" />
216 </Field>
217 <Button type="submit" variant="quiet">
218 <Plus size={14} />
219 Add
220 </Button>
221 </form>
222 </Section>
223 );
224}
225
226function MemberState({ limit }: { limit: Limit }) {
227 if (limit.state === "ok") return null;
228 return (
229 <span className="mt-1 block">
230 <StateBadge state={limit.state} />
231 </span>
232 );
233}
234
235function DetachButton({ workspace, pathname }: { workspace: string; pathname: string }) {
236 return (
237 <form method="post" action={`${pathname}#review`} className="shrink-0">
238 <input type="hidden" name="intent" value="detach" />
239 <input type="hidden" name="workspace" value={workspace} />
240 <button
241 type="submit"
242 aria-label={`Remove ${workspace}`}
243 title={`Remove ${workspace}`}
244 className="rounded-md border border-line p-2 text-muted transition-colors hover:border-danger/50 hover:text-danger"
245 >
246 <Trash2 size={14} />
247 </button>
248 </form>
249 );
250}
251
252// --- Invoices -----------------------------------------------------------------
253
254const INVOICE_STATUS: Record<string, { label: string; tone: "plain" | "mint" | "warn" | "danger" | "info" }> = {
255 open: { label: "Open", tone: "info" },
256 paid: { label: "Paid", tone: "mint" },
257 overdue: { label: "Overdue", tone: "danger" },
258 void: { label: "Void", tone: "plain" },
259};
260
261function InvoiceStatus({ status }: { status: string }) {
262 const { label, tone } = INVOICE_STATUS[status] ?? { label: status, tone: "plain" as const };
263 return <Badge tone={tone}>{label}</Badge>;
264}
265
266/** Stripe's hosted invoice page, the one the customer pays on; https only. */
267function StripeLink({ url }: { url: string | null }) {
268 if (!url || !url.startsWith("https://")) return <span className="text-faint">—</span>;
269 return (
270 <a href={url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-merged hover:underline">
271 Stripe
272 <ExternalLink size={12} />
273 </a>
274 );
275}
276
277function InvoicesSection({
278 email,
279 invoices,
280 sent,
281 pathname,
282 error,
283}: {
284 email: string | null;
285 invoices: EnterpriseInvoice[];
286 sent: EnterpriseInvoice | null;
287 pathname: string;
288 error: SectionError;
289}) {
290 return (
291 <Section id="invoices" title="Invoices" description="One Stripe invoice for every workspace it pays for, net 30, emailed by Stripe.">
292 <div className="space-y-4">
293 {error && <Notice tone="error">{error.error}</Notice>}
294 {sent && (
295 <Notice tone="ok">
296 Invoice sent: {usd(sent.amountMicros)} for {sent.period}. <StripeLink url={sent.hostedUrl} />
297 </Notice>
298 )}
299
300 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end">
301 <input type="hidden" name="intent" value="billing-email" />
302 <Field label="Invoices go to" hint={email ? "Stripe emails each invoice here." : "Not set yet. Needed before an invoice can be sent."} className="grow">
303 <Input name="email" type="email" required placeholder="billing@acme.com" defaultValue={error?.values?.email ?? email ?? ""} />
304 </Field>
305 <Button type="submit" variant="quiet">
306 <Mail size={14} />
307 {email ? "Change" : "Set"}
308 </Button>
309 </form>
310
311 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 border-t border-line pt-4 sm:flex-row sm:items-center sm:justify-between">
312 <input type="hidden" name="intent" value="invoice" />
313 <p className="text-sm text-muted">An invoice goes out on its own when each month closes. Send one now for what it owes so far.</p>
314 <Button type="submit" variant="quiet" className="shrink-0">
315 <Send size={14} />
316 Send invoice now
317 </Button>
318 </form>
319
320 {invoices.length === 0 ? (
321 <EmptyState title="No invoices yet" />
322 ) : (
323 <div className="-mx-4 -mb-4 overflow-x-auto border-t border-line sm:-mx-5 sm:-mb-5">
324 <table className="w-full min-w-xl text-sm">
325 <thead>
326 <tr className="border-b border-line text-left text-xs text-muted">
327 <th className="px-4 py-2 font-medium sm:pl-5">Period</th>
328 <th className="px-4 py-2 font-medium">Status</th>
329 <th className="px-4 py-2 font-medium">Lines</th>
330 <th className="px-4 py-2 text-right font-medium">Amount</th>
331 <th className="px-4 py-2 font-medium sm:pr-5">Page</th>
332 </tr>
333 </thead>
334 <tbody>
335 {invoices.map((invoice) => (
336 <tr key={invoice.invoiceId} className="border-b border-line align-top last:border-0">
337 <td className="px-4 py-2.5 sm:pl-5">
338 <p>{invoice.period}</p>
339 <p className="text-xs text-faint">
340 <When at={invoice.createdAt} />
341 </p>
342 </td>
343 <td className="px-4 py-2.5">
344 <InvoiceStatus status={invoice.status} />
345 </td>
346 <td className="px-4 py-2.5">
347 <ul className="space-y-0.5 text-xs">
348 {invoice.lines.map((line) => (
349 <li key={line.workspace} className="tabular">
350 <span className="font-mono text-fg-soft">{line.workspace}</span>
351 <span className="text-faint">: {usd(line.amountMicros)}</span>
352 </li>
353 ))}
354 </ul>
355 </td>
356 <td className="tabular px-4 py-2.5 text-right whitespace-nowrap">{usd(invoice.amountMicros)}</td>
357 <td className="px-4 py-2.5 text-xs sm:pr-5">
358 <StripeLink url={invoice.hostedUrl} />
359 </td>
360 </tr>
361 ))}
362 </tbody>
363 </table>
364 </div>
365 )}
366 </div>
367 </Section>
368 );
369}