sudo: Invoices, Audit log, and follow-ups due
Invoices lists every invoice g1t has sent, workspaces' and enterprises', with totals, filters by status and month, and links to each customer and to Stripe. Audit log shows every change made in sudo and by Stripe, by person and kind, paged. Reach out shows each workspace's next step and filters to follow-ups due; the Overview card links there.
13 files+756−650/13 viewed
| 22 | 22 | first (at limit, declined, near limit, high spend, growing, established, | |
| 23 | 23 | first payment), with its owners, the reason in a sentence, the figure, | |
| 24 | 24 | and its sales stage and owner at g1t. Filter by why and by whose | |
| 25 | − | (everyone's, unassigned, mine). Each row opens the workspace's Sales. | |
| 25 | + | (everyone's, unassigned, mine), or show only **follow-ups due** (a next | |
| 26 | + | step due today or earlier, on a deal not won or lost; one per | |
| 27 | + | workspace). Rows show the next step and its day. Each row opens the | |
| 28 | + | workspace's Sales. | |
| 29 | + | - **Invoices** (`/invoices`): every invoice g1t has sent, workspaces' and | |
| 30 | + | enterprises', newest first (`admin_invoices`, at most 200). Filter by | |
| 31 | + | status and month; totals for what is listed (amount, paid, outstanding); | |
| 32 | + | each links to its workspace or enterprise and to Stripe's page (https | |
| 33 | + | only). | |
| 34 | + | - **Audit log** (`/audit`): every change made in sudo, and what Stripe told | |
| 35 | + | billing, newest first, 100 a page with "Older" (`admin_audit`). Filter by | |
| 36 | + | staff email and kind of change; each line links to its workspace or | |
| 37 | + | enterprise. | |
| 26 | 38 | - **Workspaces** (`/workspaces`): every workspace, newest first, 50 to a | |
| 27 | 39 | page, with its owners, members, who it is billed to, its terms, this | |
| 28 | 40 | month's usage against its limit, what it was charged and what it cost |
| 10 | 10 | import type { AdminAction, AdminOwner, BillingLink, LedgerEntry, Terms } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | 12 | import { Avatar, Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui"; | |
| 13 | + | import { actionLabel } from "~/lib/ledgers"; | |
| 13 | 14 | import { dollarsField, usd } from "~/lib/money"; | |
| 14 | 15 | import type { Review, SectionError } from "~/lib/review"; | |
| 15 | 16 | ||
| 438 | 439 | ); | |
| 439 | 440 | } | |
| 440 | 441 | ||
| 441 | − | const ACTION: Record<string, string> = { | |
| 442 | − | terms: "Terms changed", | |
| 443 | − | create: "Enterprise created", | |
| 444 | − | attach: "Workspace added", | |
| 445 | − | detach: "Workspace removed", | |
| 446 | − | credit: "Credit issued", | |
| 447 | − | billing_link: "Billing link made", | |
| 448 | − | billing_email: "Invoice email set", | |
| 449 | − | invoice: "Invoice sent", | |
| 450 | − | dispute: "Payment disputed", | |
| 451 | − | stripe: "Stripe", | |
| 452 | − | webhook: "Webhook registered", | |
| 453 | − | }; | |
| 454 | 442 | ||
| 443 | + | ||
| 455 | 444 | export function AuditSection({ audit, description = "Every change made in sudo, and by whom." }: { audit: AdminAction[]; description?: ReactNode }) { | |
| 456 | 445 | return ( | |
| 457 | 446 | <Section title="Audit log" description={description}> | |
| 465 | 454 | {audit.map((entry) => ( | |
| 466 | 455 | <li key={entry.id} className="border-l-2 border-merged/40 pl-3"> | |
| 467 | 456 | <p className="flex flex-wrap items-center gap-x-2 text-sm"> | |
| 468 | − | <span className="font-medium text-merged">{ACTION[entry.action] ?? entry.action}</span> | |
| 457 | + | <span className="font-medium text-merged">{actionLabel(entry.action)}</span> | |
| 469 | 458 | <span className="text-xs text-faint"> | |
| 470 | 459 | <When at={entry.createdAt} time /> | |
| 471 | 460 | </span> |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { | |
| 5 | + | accountName, | |
| 6 | + | accountPath, | |
| 7 | + | actionLabel, | |
| 8 | + | auditHref, | |
| 9 | + | invoiceTotals, | |
| 10 | + | invoicesHref, | |
| 11 | + | olderBefore, | |
| 12 | + | parseAction, | |
| 13 | + | parseBefore, | |
| 14 | + | parseBy, | |
| 15 | + | parseInvoiceStatus, | |
| 16 | + | parseMonth, | |
| 17 | + | safeUrl, | |
| 18 | + | } from "./ledgers.ts"; | |
| 19 | + | ||
| 20 | + | const M = 1_000_000; | |
| 21 | + | ||
| 22 | + | test("invoice filters are checked", () => { | |
| 23 | + | assert.equal(parseInvoiceStatus("overdue"), "overdue"); | |
| 24 | + | assert.equal(parseInvoiceStatus("late"), null); | |
| 25 | + | assert.equal(parseInvoiceStatus(null), null); | |
| 26 | + | assert.equal(parseMonth("2026-10"), "2026-10"); | |
| 27 | + | assert.equal(parseMonth("2026-13"), null); | |
| 28 | + | assert.equal(parseMonth("2026-1"), null); | |
| 29 | + | assert.equal(parseMonth(""), null); | |
| 30 | + | }); | |
| 31 | + | ||
| 32 | + | test("totals: void counts for nothing; open, overdue and failed are outstanding", () => { | |
| 33 | + | const totals = invoiceTotals([ | |
| 34 | + | { amountMicros: 100 * M, status: "paid" }, | |
| 35 | + | { amountMicros: 50 * M, status: "open" }, | |
| 36 | + | { amountMicros: 20 * M, status: "failed" }, | |
| 37 | + | { amountMicros: 5 * M, status: "overdue" }, | |
| 38 | + | { amountMicros: 999 * M, status: "void" }, | |
| 39 | + | ]); | |
| 40 | + | assert.deepEqual(totals, { count: 5, amountMicros: 175 * M, paidMicros: 100 * M, outstandingMicros: 75 * M, outstanding: 3 }); | |
| 41 | + | assert.deepEqual(invoiceTotals([]), { count: 0, amountMicros: 0, paidMicros: 0, outstandingMicros: 0, outstanding: 0 }); | |
| 42 | + | }); | |
| 43 | + | ||
| 44 | + | test("links keep only the filters that are set", () => { | |
| 45 | + | assert.equal(invoicesHref({}), "/invoices"); | |
| 46 | + | assert.equal(invoicesHref({ status: "open", month: "2026-10" }), "/invoices?status=open&month=2026-10"); | |
| 47 | + | assert.equal(auditHref({}), "/audit"); | |
| 48 | + | assert.equal(auditHref({ by: "me@g1t.sh", before: "2026-10-01T00:00:00Z" }), "/audit?by=me%40g1t.sh&before=2026-10-01T00%3A00%3A00Z"); | |
| 49 | + | }); | |
| 50 | + | ||
| 51 | + | test("only https links are followed", () => { | |
| 52 | + | assert.equal(safeUrl("https://invoice.stripe.com/i/x"), "https://invoice.stripe.com/i/x"); | |
| 53 | + | assert.equal(safeUrl("javascript:alert(1)"), null); | |
| 54 | + | assert.equal(safeUrl("http://invoice.stripe.com"), null); | |
| 55 | + | assert.equal(safeUrl(null), null); | |
| 56 | + | }); | |
| 57 | + | ||
| 58 | + | test("account ids link to their pages; anything else does not", () => { | |
| 59 | + | assert.equal(accountPath("ws_acme"), "/workspaces/acme"); | |
| 60 | + | assert.equal(accountPath("ent_bigco"), "/enterprises/ent_bigco"); | |
| 61 | + | assert.equal(accountPath("stripe"), null); | |
| 62 | + | assert.equal(accountPath("acme"), null); | |
| 63 | + | assert.equal(accountPath("ws_-bad"), null); | |
| 64 | + | assert.equal(accountPath(""), null); | |
| 65 | + | assert.equal(accountPath(null), null); | |
| 66 | + | }); | |
| 67 | + | ||
| 68 | + | test("accounts are named by slug, or the enterprise's name when known", () => { | |
| 69 | + | assert.equal(accountName("ws_acme"), "acme"); | |
| 70 | + | assert.equal(accountName("ent_bigco", new Map([["ent_bigco", "BigCo Inc"]])), "BigCo Inc"); | |
| 71 | + | assert.equal(accountName("ent_bigco"), "an enterprise"); | |
| 72 | + | assert.equal(accountName("stripe"), "stripe"); | |
| 73 | + | }); | |
| 74 | + | ||
| 75 | + | test("actions read plainly, known or not", () => { | |
| 76 | + | assert.equal(actionLabel("credit"), "Credit issued"); | |
| 77 | + | assert.equal(actionLabel("stripe"), "From Stripe"); | |
| 78 | + | assert.equal(actionLabel("webhook"), "Stripe webhook registered"); | |
| 79 | + | assert.equal(actionLabel("price_change"), "Price change"); | |
| 80 | + | }); | |
| 81 | + | ||
| 82 | + | test("an older page only when this one was full", () => { | |
| 83 | + | const page = Array.from({ length: 100 }, (_, index) => ({ createdAt: `2026-10-01T00:00:${String(index % 60).padStart(2, "0")}Z` })); | |
| 84 | + | assert.equal(olderBefore(page), page[99].createdAt); | |
| 85 | + | assert.equal(olderBefore(page.slice(0, 99)), null); | |
| 86 | + | assert.equal(olderBefore([]), null); | |
| 87 | + | }); | |
| 88 | + | ||
| 89 | + | test("audit filters are checked", () => { | |
| 90 | + | assert.equal(parseBy(" Me@G1t.sh "), "me@g1t.sh"); | |
| 91 | + | assert.equal(parseBy("two words"), null); | |
| 92 | + | assert.equal(parseBy(""), null); | |
| 93 | + | assert.equal(parseAction("billing_link"), "billing_link"); | |
| 94 | + | assert.equal(parseAction("drop table"), null); | |
| 95 | + | assert.equal(parseBefore("2026-10-01T00:00:00Z"), "2026-10-01T00:00:00Z"); | |
| 96 | + | assert.equal(parseBefore("yesterday"), null); | |
| 97 | + | }); |
| 1 | + | /** | |
| 2 | + | * The Invoices and Audit log pages' arithmetic and wording: filters read | |
| 3 | + | * from the address, totals, links from billing's account ids to sudo's | |
| 4 | + | * pages, and paging. No Workers imports, so it can be tested under Node. | |
| 5 | + | */ | |
| 6 | + | import type { AdminAction, InvoiceSummary } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | // --- Invoices ----------------------------------------------------------------- | |
| 9 | + | ||
| 10 | + | export const INVOICE_STATUSES = ["open", "overdue", "failed", "paid", "void"] as const; | |
| 11 | + | export type InvoiceStatusFilter = (typeof INVOICE_STATUSES)[number]; | |
| 12 | + | ||
| 13 | + | export function parseInvoiceStatus(value: string | null): InvoiceStatusFilter | null { | |
| 14 | + | return INVOICE_STATUSES.find((status) => status === value) ?? null; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | /** A month as `<input type="month">` sends it: `2026-10`. */ | |
| 18 | + | export function parseMonth(value: string | null): string | null { | |
| 19 | + | if (!value || !/^\d{4}-(0[1-9]|1[0-2])$/.test(value)) return null; | |
| 20 | + | return value; | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /** Not paid and not void: what is still owed. */ | |
| 24 | + | export function isOutstanding(status: string): boolean { | |
| 25 | + | return status === "open" || status === "overdue" || status === "failed"; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | export type InvoiceTotals = { count: number; amountMicros: number; paidMicros: number; outstandingMicros: number; outstanding: number }; | |
| 29 | + | ||
| 30 | + | export function invoiceTotals(invoices: Pick<InvoiceSummary, "amountMicros" | "status">[]): InvoiceTotals { | |
| 31 | + | const totals: InvoiceTotals = { count: invoices.length, amountMicros: 0, paidMicros: 0, outstandingMicros: 0, outstanding: 0 }; | |
| 32 | + | for (const invoice of invoices) { | |
| 33 | + | if (invoice.status === "void") continue; | |
| 34 | + | totals.amountMicros += invoice.amountMicros; | |
| 35 | + | if (invoice.status === "paid") totals.paidMicros += invoice.amountMicros; | |
| 36 | + | if (isOutstanding(invoice.status)) { | |
| 37 | + | totals.outstandingMicros += invoice.amountMicros; | |
| 38 | + | totals.outstanding += 1; | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | return totals; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /** A link to the invoices list with these filters; the defaults are left out. */ | |
| 45 | + | export function invoicesHref({ status, month }: { status?: string | null; month?: string | null }): string { | |
| 46 | + | const params = new URLSearchParams(); | |
| 47 | + | if (status) params.set("status", status); | |
| 48 | + | if (month) params.set("month", month); | |
| 49 | + | const query = params.toString(); | |
| 50 | + | return query ? `/invoices?${query}` : "/invoices"; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /** Only https links to Stripe are followed. */ | |
| 54 | + | export function safeUrl(url: string | null | undefined): string | null { | |
| 55 | + | return url && url.startsWith("https://") ? url : null; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // --- Accounts ------------------------------------------------------------------- | |
| 59 | + | ||
| 60 | + | const SLUG = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/; | |
| 61 | + | const ENTERPRISE = /^ent_[a-z0-9_-]{1,80}$/; | |
| 62 | + | ||
| 63 | + | /** | |
| 64 | + | * The sudo page for one of billing's account ids: `ws_<slug>` is the | |
| 65 | + | * workspace's, `ent_…` the enterprise's. Null for anything else, such as | |
| 66 | + | * Stripe's own lines. | |
| 67 | + | */ | |
| 68 | + | export function accountPath(account: string | null | undefined): string | null { | |
| 69 | + | const id = (account ?? "").trim().toLowerCase(); | |
| 70 | + | if (ENTERPRISE.test(id)) return `/enterprises/${encodeURIComponent(id)}`; | |
| 71 | + | if (id.startsWith("ws_") && SLUG.test(id.slice(3))) return `/workspaces/${encodeURIComponent(id.slice(3))}`; | |
| 72 | + | return null; | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /** What to call an account: a workspace by its slug, an enterprise by its name when known. */ | |
| 76 | + | export function accountName(account: string, names: Map<string, string> = new Map()): string { | |
| 77 | + | if (names.has(account)) return names.get(account) as string; | |
| 78 | + | if (account.startsWith("ws_")) return account.slice(3); | |
| 79 | + | if (ENTERPRISE.test(account)) return "an enterprise"; | |
| 80 | + | return account; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // --- Audit ---------------------------------------------------------------------- | |
| 84 | + | ||
| 85 | + | /** Each kind of change, as staff read it. */ | |
| 86 | + | export const AUDIT_ACTIONS: Record<string, string> = { | |
| 87 | + | terms: "Terms changed", | |
| 88 | + | create: "Enterprise created", | |
| 89 | + | attach: "Workspace added", | |
| 90 | + | detach: "Workspace removed", | |
| 91 | + | credit: "Credit issued", | |
| 92 | + | billing_link: "Billing link made", | |
| 93 | + | billing_email: "Invoice email set", | |
| 94 | + | invoice: "Invoice sent", | |
| 95 | + | dispute: "Payment disputed", | |
| 96 | + | sales: "Sales record changed", | |
| 97 | + | note: "Sales note added", | |
| 98 | + | stripe: "From Stripe", | |
| 99 | + | webhook: "Stripe webhook registered", | |
| 100 | + | }; | |
| 101 | + | ||
| 102 | + | export function actionLabel(action: string): string { | |
| 103 | + | return AUDIT_ACTIONS[action] ?? action.replace(/_/g, " ").replace(/^./, (char) => char.toUpperCase()); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | export const AUDIT_PAGE = 100; | |
| 107 | + | ||
| 108 | + | /** Where the next, older page starts: after the last line, if this page was full. */ | |
| 109 | + | export function olderBefore(actions: Pick<AdminAction, "createdAt">[], page = AUDIT_PAGE): string | null { | |
| 110 | + | return actions.length >= page ? (actions.at(-1)?.createdAt ?? null) : null; | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /** A staff email filter: lowercased, at most one address's length. */ | |
| 114 | + | export function parseBy(value: string | null): string | null { | |
| 115 | + | const by = (value ?? "").trim().toLowerCase(); | |
| 116 | + | return by && by.length <= 254 && !/\s/.test(by) ? by : null; | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | export function parseAction(value: string | null): string | null { | |
| 120 | + | const action = (value ?? "").trim(); | |
| 121 | + | return /^[a-z_]{1,40}$/.test(action) ? action : null; | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /** An RFC 3339 time to page from. */ | |
| 125 | + | export function parseBefore(value: string | null): string | null { | |
| 126 | + | if (!value || value.length > 40 || Number.isNaN(new Date(value).getTime())) return null; | |
| 127 | + | return value; | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | export function auditHref({ by, action, before }: { by?: string | null; action?: string | null; before?: string | null }): string { | |
| 131 | + | const params = new URLSearchParams(); | |
| 132 | + | if (by) params.set("by", by); | |
| 133 | + | if (action) params.set("action", action); | |
| 134 | + | if (before) params.set("before", before); | |
| 135 | + | const query = params.toString(); | |
| 136 | + | return query ? `/audit?${query}` : "/audit"; | |
| 137 | + | } |
| 3 | 3 | ||
| 4 | 4 | import type { Signal } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { bySignalUrgency, countByKind, filterSignals, isSignalKind, parseWho, reachOutHref, signalMeta, stageMeta } from "./signals.ts"; | |
| 6 | + | import { bySignalUrgency, countByKind, filterSignals, followUpsDue, isFollowUpDue, isSignalKind, parseWho, reachOutHref, signalMeta, stageMeta, today } from "./signals.ts"; | |
| 7 | 7 | ||
| 8 | 8 | function signal(workspace: string, kind: string, valueMicros = 0, owner: string | null = null): Signal { | |
| 9 | 9 | return { workspace, kind: kind as Signal["kind"], detail: `${workspace} ${kind}`, valueMicros, stage: null, owner }; | |
| 76 | 76 | assert.equal(stageMeta("churn_risk").label, "Churn risk"); | |
| 77 | 77 | assert.equal(stageMeta("odd").label, "odd"); | |
| 78 | 78 | }); | |
| 79 | + | ||
| 80 | + | test("follow-ups due: today or earlier, open deals only, one per workspace, oldest first", () => { | |
| 81 | + | const s = (workspace: string, nextAt: string | null, stage: string | null = "contacted", kind = "growing"): Signal => ({ | |
| 82 | + | ...signal(workspace, kind), | |
| 83 | + | nextAt, | |
| 84 | + | stage, | |
| 85 | + | nextStep: nextAt ? "Call" : null, | |
| 86 | + | }); | |
| 87 | + | const on = "2026-10-04"; | |
| 88 | + | assert.ok(isFollowUpDue(s("a", "2026-10-04"), on)); | |
| 89 | + | assert.ok(isFollowUpDue(s("a", "2026-10-01"), on)); | |
| 90 | + | assert.ok(!isFollowUpDue(s("a", "2026-10-05"), on)); | |
| 91 | + | assert.ok(!isFollowUpDue(s("a", null), on)); | |
| 92 | + | assert.ok(!isFollowUpDue(s("a", "2026-10-01", "won"), on)); | |
| 93 | + | assert.ok(!isFollowUpDue(s("a", "2026-10-01", "lost"), on)); | |
| 94 | + | assert.ok(isFollowUpDue(s("a", "2026-10-01", null), on)); | |
| 95 | + | const due = followUpsDue([s("b", "2026-10-03"), s("a", "2026-09-30", "contacted", "at_limit"), s("a", "2026-09-30", "contacted", "high_spend"), s("c", "2026-10-09")], on); | |
| 96 | + | assert.deepEqual(due.map((x) => `${x.workspace}:${x.kind}`), ["a:at_limit", "b:growing"]); | |
| 97 | + | assert.equal(today(new Date("2026-10-04T23:59:00Z")), "2026-10-04"); | |
| 98 | + | assert.equal(reachOutHref({ due: true }), "/reach-out?due=1"); | |
| 99 | + | assert.equal(reachOutHref({ due: true, who: "mine" }), "/reach-out?due=1&who=mine"); | |
| 100 | + | }); |
| 66 | 66 | }); | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 69 | + | /** Today, as billing compares follow-up days: `YYYY-MM-DD`, UTC. */ | |
| 70 | + | export function today(now = new Date()): string { | |
| 71 | + | return now.toISOString().slice(0, 10); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /** A next step due today or earlier, on a deal that is still open. */ | |
| 75 | + | export function isFollowUpDue(signal: Pick<Signal, "nextAt" | "stage">, on = today()): boolean { | |
| 76 | + | if (!signal.nextAt || signal.stage === "won" || signal.stage === "lost") return false; | |
| 77 | + | return signal.nextAt.slice(0, 10) <= on; | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /** | |
| 81 | + | * Follow-ups due, one per workspace (a workspace with several signals has | |
| 82 | + | * one sales record), the longest overdue first. | |
| 83 | + | */ | |
| 84 | + | export function followUpsDue(signals: Signal[], on = today()): Signal[] { | |
| 85 | + | const seen = new Set<string>(); | |
| 86 | + | return signals | |
| 87 | + | .filter((signal) => isFollowUpDue(signal, on)) | |
| 88 | + | .filter((signal) => (seen.has(signal.workspace) ? false : (seen.add(signal.workspace), true))) | |
| 89 | + | .map((signal, index) => ({ signal, index })) | |
| 90 | + | .sort((a, b) => (a.signal.nextAt ?? "").localeCompare(b.signal.nextAt ?? "") || a.index - b.index) | |
| 91 | + | .map(({ signal }) => signal); | |
| 92 | + | } | |
| 93 | + | ||
| 69 | 94 | /** How many signals of each kind, for the filter's counts. */ | |
| 70 | 95 | export function countByKind(signals: Signal[]): Record<string, number> { | |
| 71 | 96 | const counts: Record<string, number> = {}; | |
| 74 | 99 | } | |
| 75 | 100 | ||
| 76 | 101 | /** A link to the queue with these filters; the defaults are left out. */ | |
| 77 | − | export function reachOutHref({ kind, who }: { kind?: string | null; who?: Who }): string { | |
| 102 | + | export function reachOutHref({ kind, who, due }: { kind?: string | null; who?: Who; due?: boolean }): string { | |
| 78 | 103 | const params = new URLSearchParams(); | |
| 104 | + | if (due) params.set("due", "1"); | |
| 79 | 105 | if (kind) params.set("kind", kind); | |
| 80 | 106 | if (who && who !== "all") params.set("who", who); | |
| 81 | 107 | const query = params.toString(); |
| 10 | 10 | route("enterprises/new", "routes/new-enterprise.tsx"), | |
| 11 | 11 | route("enterprises/:id", "routes/enterprise.tsx"), | |
| 12 | 12 | route("reach-out", "routes/reach-out.tsx"), | |
| 13 | + | route("invoices", "routes/invoices.tsx"), | |
| 13 | 14 | route("stripe", "routes/stripe.tsx"), | |
| 15 | + | route("audit", "routes/audit.tsx"), | |
| 14 | 16 | // What sudo will have: one placeholder page each, from lib/nav.ts. | |
| 15 | 17 | ...soonItems().map((item) => route(item.to.slice(1), "routes/soon.tsx", { id: `soon${item.to.replace(/\//g, "-")}` })), | |
| 16 | 18 | // Before sudo was organised around workspaces, everything was an account. |
| 1 | + | import { ChevronRight, UserRound } from "lucide-react"; | |
| 2 | + | import { Link } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { AdminAction } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/audit"; | |
| 7 | + | import { Button, EmptyState, Input, Notice, PageHeader, Select, When } from "~/components/ui"; | |
| 8 | + | import { | |
| 9 | + | AUDIT_ACTIONS, | |
| 10 | + | AUDIT_PAGE, | |
| 11 | + | accountName, | |
| 12 | + | accountPath, | |
| 13 | + | actionLabel, | |
| 14 | + | auditHref, | |
| 15 | + | olderBefore, | |
| 16 | + | parseAction, | |
| 17 | + | parseBefore, | |
| 18 | + | parseBy, | |
| 19 | + | } from "~/lib/ledgers"; | |
| 20 | + | import { admin } from "~/lib/services.server"; | |
| 21 | + | import { settle } from "~/lib/settle"; | |
| 22 | + | import { requireStaff } from "~/lib/staff"; | |
| 23 | + | ||
| 24 | + | export const meta: Route.MetaFunction = () => [{ title: "Audit log · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 25 | + | ||
| 26 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 27 | + | const staff = requireStaff(context); | |
| 28 | + | const url = new URL(request.url); | |
| 29 | + | const by = parseBy(url.searchParams.get("by")); | |
| 30 | + | const action = parseAction(url.searchParams.get("action")); | |
| 31 | + | const before = parseBefore(url.searchParams.get("before")); | |
| 32 | + | // Enterprises' names, so a line about one reads as its name, not its id. | |
| 33 | + | const [result, accounts] = await Promise.all([ | |
| 34 | + | settle(admin.audit({ by: by ?? undefined, action: action ?? undefined, before: before ?? undefined })), | |
| 35 | + | settle(admin.accounts()), | |
| 36 | + | ]); | |
| 37 | + | const names = Object.fromEntries( | |
| 38 | + | (accounts.ok ? accounts.value : []).filter((row) => row.account.kind === "enterprise").map((row) => [row.account.id, row.account.name]), | |
| 39 | + | ); | |
| 40 | + | const actions = result.ok ? result.value : []; | |
| 41 | + | return { | |
| 42 | + | me: staff.email, | |
| 43 | + | by, | |
| 44 | + | action, | |
| 45 | + | before, | |
| 46 | + | actions, | |
| 47 | + | names, | |
| 48 | + | older: olderBefore(actions), | |
| 49 | + | error: result.ok ? null : result.error, | |
| 50 | + | }; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /** Where a change came from: Stripe's lines are about Stripe, not a person. */ | |
| 54 | + | function isStripe(entry: AdminAction) { | |
| 55 | + | return entry.action === "stripe" || entry.action === "webhook" || entry.by === "stripe"; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | export default function Audit({ loaderData }: Route.ComponentProps) { | |
| 59 | + | const { me, by, action, before, actions, names, older, error } = loaderData; | |
| 60 | + | const nameMap = new Map(Object.entries(names)); | |
| 61 | + | const filtered = by != null || action != null; | |
| 62 | + | ||
| 63 | + | return ( | |
| 64 | + | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 65 | + | <PageHeader | |
| 66 | + | title="Audit log" | |
| 67 | + | description="Every change made in sudo, and what Stripe told billing, newest first, across every customer. Each names who made it." | |
| 68 | + | /> | |
| 69 | + | ||
| 70 | + | <form method="get" action="/audit" className="mt-6 flex flex-col gap-2 sm:flex-row sm:items-center"> | |
| 71 | + | <Input name="by" type="search" defaultValue={by ?? ""} placeholder="Staff email, such as you@g1t.sh" aria-label="Made by" className="font-mono text-xs sm:w-72" /> | |
| 72 | + | <Select name="action" defaultValue={action ?? ""} aria-label="Kind of change" className="sm:w-60"> | |
| 73 | + | <option value="">Every kind of change</option> | |
| 74 | + | {Object.entries(AUDIT_ACTIONS).map(([value, label]) => ( | |
| 75 | + | <option key={value} value={value}> | |
| 76 | + | {label} | |
| 77 | + | </option> | |
| 78 | + | ))} | |
| 79 | + | </Select> | |
| 80 | + | <div className="flex items-center gap-2"> | |
| 81 | + | <Button type="submit" variant="quiet" className="py-2"> | |
| 82 | + | Apply | |
| 83 | + | </Button> | |
| 84 | + | <Link to={auditHref({ by: me })} className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 85 | + | Mine | |
| 86 | + | </Link> | |
| 87 | + | {filtered && ( | |
| 88 | + | <Link to="/audit" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 89 | + | Clear | |
| 90 | + | </Link> | |
| 91 | + | )} | |
| 92 | + | </div> | |
| 93 | + | </form> | |
| 94 | + | ||
| 95 | + | {before && ( | |
| 96 | + | <p className="mt-4 text-xs text-faint"> | |
| 97 | + | Changes before <When at={before} time />.{" "} | |
| 98 | + | <Link to={auditHref({ by, action })} className="text-merged hover:underline"> | |
| 99 | + | Back to the newest | |
| 100 | + | </Link> | |
| 101 | + | </p> | |
| 102 | + | )} | |
| 103 | + | ||
| 104 | + | {error ? ( | |
| 105 | + | <div className="mt-4"> | |
| 106 | + | <Notice tone="warn">Billing did not answer for the audit log: {error}</Notice> | |
| 107 | + | </div> | |
| 108 | + | ) : actions.length === 0 ? ( | |
| 109 | + | <div className="mt-4"> | |
| 110 | + | <EmptyState title={filtered || before ? "No changes match" : "No changes yet"}> | |
| 111 | + | {filtered ? "Try another staff email or kind of change, or clear the filters." : "Changes appear here as staff make them."} | |
| 112 | + | </EmptyState> | |
| 113 | + | </div> | |
| 114 | + | ) : ( | |
| 115 | + | <ol className="mt-4 divide-y divide-line rounded-lg border border-line bg-surface"> | |
| 116 | + | {actions.map((entry) => { | |
| 117 | + | const path = accountPath(entry.account); | |
| 118 | + | const stripe = isStripe(entry); | |
| 119 | + | return ( | |
| 120 | + | <li key={entry.id} className="flex flex-col gap-1.5 px-4 py-3 sm:flex-row sm:items-start sm:gap-4 sm:px-5"> | |
| 121 | + | <p className="shrink-0 text-xs whitespace-nowrap text-faint sm:w-44 sm:pt-0.5"> | |
| 122 | + | <When at={entry.createdAt} time /> | |
| 123 | + | </p> | |
| 124 | + | <div className="min-w-0 grow"> | |
| 125 | + | <p className="flex flex-wrap items-center gap-x-2 text-sm"> | |
| 126 | + | <span className={`font-medium ${stripe ? "text-info" : "text-merged"}`}>{actionLabel(entry.action)}</span> | |
| 127 | + | {path && ( | |
| 128 | + | <Link to={path} className="inline-flex items-center gap-0.5 text-fg-soft hover:text-fg hover:underline hover:underline-offset-4"> | |
| 129 | + | <span className={entry.account.startsWith("ws_") ? "font-mono text-xs" : ""}>{accountName(entry.account, nameMap)}</span> | |
| 130 | + | <ChevronRight size={12} className="text-faint" /> | |
| 131 | + | </Link> | |
| 132 | + | )} | |
| 133 | + | </p> | |
| 134 | + | {entry.detail && <p className="mt-0.5 text-sm break-words text-muted">{entry.detail}</p>} | |
| 135 | + | </div> | |
| 136 | + | <p className="flex shrink-0 items-center gap-1 text-xs text-faint sm:w-56 sm:justify-end sm:pt-0.5"> | |
| 137 | + | {stripe && entry.by === "stripe" ? ( | |
| 138 | + | "Stripe" | |
| 139 | + | ) : ( | |
| 140 | + | <Link to={auditHref({ by: entry.by, action })} title={`Every change by ${entry.by}`} className="inline-flex min-w-0 items-center gap-1 hover:text-fg"> | |
| 141 | + | <UserRound size={11} className="shrink-0" /> | |
| 142 | + | <span className="truncate font-mono">{entry.by.toLowerCase() === me.toLowerCase() ? "you" : entry.by}</span> | |
| 143 | + | </Link> | |
| 144 | + | )} | |
| 145 | + | </p> | |
| 146 | + | </li> | |
| 147 | + | ); | |
| 148 | + | })} | |
| 149 | + | </ol> | |
| 150 | + | )} | |
| 151 | + | ||
| 152 | + | {(older || actions.length > 0) && !error && ( | |
| 153 | + | <nav aria-label="Pages" className="mt-4 flex items-center justify-between gap-3 text-sm"> | |
| 154 | + | <span className="text-xs text-faint"> | |
| 155 | + | {actions.length} change{actions.length === 1 ? "" : "s"} | |
| 156 | + | {older ? `, ${AUDIT_PAGE} a page` : ""} | |
| 157 | + | </span> | |
| 158 | + | {older && ( | |
| 159 | + | <Link | |
| 160 | + | to={auditHref({ by, action, before: older })} | |
| 161 | + | className="inline-flex items-center gap-1 rounded-md border border-line px-3 py-1.5 text-muted hover:border-line-strong hover:text-fg" | |
| 162 | + | > | |
| 163 | + | Older | |
| 164 | + | <ChevronRight size={14} /> | |
| 165 | + | </Link> | |
| 166 | + | )} | |
| 167 | + | </nav> | |
| 168 | + | )} | |
| 169 | + | </main> | |
| 170 | + | ); | |
| 171 | + | } |
| 1 | + | import { ExternalLink } from "lucide-react"; | |
| 2 | + | import { Link } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { InvoiceSummary } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/invoices"; | |
| 7 | + | import { InvoiceStatus } from "~/components/sales"; | |
| 8 | + | import { Avatar, Badge, Button, EmptyState, Input, Notice, PageHeader, Select, Stat, When } from "~/components/ui"; | |
| 9 | + | import { monthLong } from "~/lib/chart"; | |
| 10 | + | import { INVOICE_STATUSES, accountPath, invoiceTotals, parseInvoiceStatus, parseMonth, safeUrl } from "~/lib/ledgers"; | |
| 11 | + | import { usd } from "~/lib/money"; | |
| 12 | + | import { admin } from "~/lib/services.server"; | |
| 13 | + | import { settle } from "~/lib/settle"; | |
| 14 | + | import { requireStaff } from "~/lib/staff"; | |
| 15 | + | ||
| 16 | + | export const meta: Route.MetaFunction = () => [{ title: "Invoices · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 17 | + | ||
| 18 | + | /** The most billing returns in one list. */ | |
| 19 | + | const MOST = 200; | |
| 20 | + | ||
| 21 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 22 | + | requireStaff(context); | |
| 23 | + | const url = new URL(request.url); | |
| 24 | + | const status = parseInvoiceStatus(url.searchParams.get("status")); | |
| 25 | + | const month = parseMonth(url.searchParams.get("month")); | |
| 26 | + | const result = await settle(admin.allInvoices({ status: status ?? undefined, month: month ?? undefined })); | |
| 27 | + | const invoices = result.ok ? result.value : []; | |
| 28 | + | return { | |
| 29 | + | status, | |
| 30 | + | month, | |
| 31 | + | invoices, | |
| 32 | + | totals: invoiceTotals(invoices), | |
| 33 | + | capped: invoices.length >= MOST, | |
| 34 | + | error: result.ok ? null : result.error, | |
| 35 | + | }; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | const STATUS_LABEL: Record<string, string> = { open: "Open", overdue: "Overdue", failed: "Failed", paid: "Paid", void: "Void" }; | |
| 39 | + | const REASON: Record<string, string> = { month: "Monthly", threshold: "Near the limit", manual: "Sent by staff" }; | |
| 40 | + | ||
| 41 | + | export default function Invoices({ loaderData }: Route.ComponentProps) { | |
| 42 | + | const { status, month, invoices, totals, capped, error } = loaderData; | |
| 43 | + | const filtered = status != null || month != null; | |
| 44 | + | const scope = [status ? STATUS_LABEL[status].toLowerCase() : null, month ? `in ${monthLong(month)}` : null].filter(Boolean).join(", "); | |
| 45 | + | ||
| 46 | + | return ( | |
| 47 | + | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| 48 | + | <PageHeader | |
| 49 | + | title="Invoices" | |
| 50 | + | description="Every invoice g1t has sent, newest first: workspaces' monthly and near-the-limit invoices, and enterprises' net-30 ones." | |
| 51 | + | /> | |
| 52 | + | ||
| 53 | + | {error ? ( | |
| 54 | + | <div className="mt-6"> | |
| 55 | + | <Notice tone="warn">Billing did not answer for invoices: {error}</Notice> | |
| 56 | + | </div> | |
| 57 | + | ) : ( | |
| 58 | + | <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4"> | |
| 59 | + | <Stat label={filtered ? `Invoices, ${scope}` : "Invoices"} value={`${totals.count}${capped ? "+" : ""}`} hint={capped ? `The newest ${MOST}` : "Void ones count for nothing"} /> | |
| 60 | + | <Stat label="Amount" value={usd(totals.amountMicros)} hint="Everything not void" /> | |
| 61 | + | <Stat label="Paid" value={usd(totals.paidMicros)} tone={totals.paidMicros > 0 ? "mint" : undefined} /> | |
| 62 | + | <Stat | |
| 63 | + | label="Outstanding" | |
| 64 | + | value={usd(totals.outstandingMicros)} | |
| 65 | + | hint={`${totals.outstanding} open, overdue or failed`} | |
| 66 | + | tone={totals.outstanding > 0 ? "warn" : undefined} | |
| 67 | + | /> | |
| 68 | + | </div> | |
| 69 | + | )} | |
| 70 | + | ||
| 71 | + | <form method="get" action="/invoices" className="mt-6 flex flex-col gap-2 sm:flex-row sm:items-center"> | |
| 72 | + | <Select name="status" defaultValue={status ?? ""} aria-label="Status" className="sm:w-44"> | |
| 73 | + | <option value="">Any status</option> | |
| 74 | + | {INVOICE_STATUSES.map((value) => ( | |
| 75 | + | <option key={value} value={value}> | |
| 76 | + | {STATUS_LABEL[value]} | |
| 77 | + | </option> | |
| 78 | + | ))} | |
| 79 | + | </Select> | |
| 80 | + | <Input type="month" name="month" defaultValue={month ?? ""} aria-label="Month" className="sm:w-48" /> | |
| 81 | + | <div className="flex items-center gap-2"> | |
| 82 | + | <Button type="submit" variant="quiet" className="py-2"> | |
| 83 | + | Apply | |
| 84 | + | </Button> | |
| 85 | + | {filtered && ( | |
| 86 | + | <Link to="/invoices" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 87 | + | Clear | |
| 88 | + | </Link> | |
| 89 | + | )} | |
| 90 | + | </div> | |
| 91 | + | </form> | |
| 92 | + | ||
| 93 | + | {!error && | |
| 94 | + | (invoices.length === 0 ? ( | |
| 95 | + | <div className="mt-4"> | |
| 96 | + | <EmptyState title={filtered ? "No invoices match" : "No invoices yet"}> | |
| 97 | + | {filtered ? "Try another status or month, or clear the filters." : "Invoices appear here as months close and workspaces are charged near their limits."} | |
| 98 | + | </EmptyState> | |
| 99 | + | </div> | |
| 100 | + | ) : ( | |
| 101 | + | <> | |
| 102 | + | {/* Phones: one card per invoice. */} | |
| 103 | + | <ul className="mt-4 space-y-2 md:hidden"> | |
| 104 | + | {invoices.map((invoice) => ( | |
| 105 | + | <li key={invoice.invoiceId} className="rounded-lg border border-line bg-surface p-4"> | |
| 106 | + | <div className="flex items-start justify-between gap-3"> | |
| 107 | + | <Customer invoice={invoice} /> | |
| 108 | + | <span className="tabular shrink-0 text-sm font-medium">{usd(invoice.amountMicros)}</span> | |
| 109 | + | </div> | |
| 110 | + | <div className="mt-3 flex flex-wrap items-center gap-2 text-xs"> | |
| 111 | + | <InvoiceStatus status={invoice.status} /> | |
| 112 | + | <span className="text-muted">{invoice.period}</span> | |
| 113 | + | <span className="text-faint">· {REASON[invoice.reason] ?? invoice.reason}</span> | |
| 114 | + | </div> | |
| 115 | + | <div className="mt-3 flex items-center justify-between gap-3 border-t border-line pt-3 text-xs text-faint"> | |
| 116 | + | <Dates invoice={invoice} /> | |
| 117 | + | <StripeLink url={invoice.hostedUrl} /> | |
| 118 | + | </div> | |
| 119 | + | </li> | |
| 120 | + | ))} | |
| 121 | + | </ul> | |
| 122 | + | ||
| 123 | + | {/* Wider screens: a table. */} | |
| 124 | + | <div className="mt-4 hidden overflow-x-auto rounded-lg border border-line md:block"> | |
| 125 | + | <table className="w-full text-sm"> | |
| 126 | + | <thead> | |
| 127 | + | <tr className="border-b border-line bg-surface text-left text-xs text-muted"> | |
| 128 | + | <th className="px-4 py-2.5 font-medium">Customer</th> | |
| 129 | + | <th className="px-4 py-2.5 font-medium">For</th> | |
| 130 | + | <th className="px-4 py-2.5 text-right font-medium">Amount</th> | |
| 131 | + | <th className="px-4 py-2.5 font-medium">Status</th> | |
| 132 | + | <th className="px-4 py-2.5 font-medium">Sent · paid</th> | |
| 133 | + | <th className="px-4 py-2.5 font-medium">On Stripe</th> | |
| 134 | + | </tr> | |
| 135 | + | </thead> | |
| 136 | + | <tbody> | |
| 137 | + | {invoices.map((invoice) => ( | |
| 138 | + | <tr key={invoice.invoiceId} className="border-b border-line align-top last:border-0 hover:bg-surface/60"> | |
| 139 | + | <td className="px-4 py-3"> | |
| 140 | + | <Customer invoice={invoice} /> | |
| 141 | + | </td> | |
| 142 | + | <td className="px-4 py-3"> | |
| 143 | + | <p className="text-fg-soft">{invoice.period}</p> | |
| 144 | + | <p className="text-xs text-faint">{REASON[invoice.reason] ?? invoice.reason}</p> | |
| 145 | + | </td> | |
| 146 | + | <td className="tabular px-4 py-3 text-right whitespace-nowrap">{usd(invoice.amountMicros)}</td> | |
| 147 | + | <td className="px-4 py-3"> | |
| 148 | + | <InvoiceStatus status={invoice.status} /> | |
| 149 | + | </td> | |
| 150 | + | <td className="px-4 py-3 text-xs whitespace-nowrap text-muted"> | |
| 151 | + | <Dates invoice={invoice} /> | |
| 152 | + | </td> | |
| 153 | + | <td className="px-4 py-3 text-xs"> | |
| 154 | + | <StripeLink url={invoice.hostedUrl} /> | |
| 155 | + | </td> | |
| 156 | + | </tr> | |
| 157 | + | ))} | |
| 158 | + | </tbody> | |
| 159 | + | </table> | |
| 160 | + | </div> | |
| 161 | + | {capped && <p className="mt-3 text-xs text-faint">Only the newest {MOST} are listed; filter by month or status to see older ones.</p>} | |
| 162 | + | </> | |
| 163 | + | ))} | |
| 164 | + | </main> | |
| 165 | + | ); | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | function Customer({ invoice }: { invoice: InvoiceSummary }) { | |
| 169 | + | const path = accountPath(invoice.account); | |
| 170 | + | const name = ( | |
| 171 | + | <span className="flex min-w-0 items-center gap-2"> | |
| 172 | + | <Avatar name={invoice.name} size={18} /> | |
| 173 | + | <span className={`truncate ${invoice.kind === "workspace" ? "font-mono text-[0.8125rem]" : "font-medium"}`}>{invoice.name}</span> | |
| 174 | + | </span> | |
| 175 | + | ); | |
| 176 | + | return ( | |
| 177 | + | <div className="min-w-0"> | |
| 178 | + | {path ? ( | |
| 179 | + | <Link to={path} className="block hover:underline hover:underline-offset-4"> | |
| 180 | + | {name} | |
| 181 | + | </Link> | |
| 182 | + | ) : ( | |
| 183 | + | name | |
| 184 | + | )} | |
| 185 | + | <div className="mt-1.5"> | |
| 186 | + | {invoice.kind === "enterprise" ? <Badge tone="lavender">Enterprise</Badge> : <Badge>Workspace</Badge>} | |
| 187 | + | </div> | |
| 188 | + | </div> | |
| 189 | + | ); | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | function Dates({ invoice }: { invoice: InvoiceSummary }) { | |
| 193 | + | return ( | |
| 194 | + | <span> | |
| 195 | + | <When at={invoice.createdAt} /> | |
| 196 | + | {invoice.paidAt ? ( | |
| 197 | + | <span className="text-accent"> | |
| 198 | + | {" · "}paid <When at={invoice.paidAt} /> | |
| 199 | + | </span> | |
| 200 | + | ) : null} | |
| 201 | + | </span> | |
| 202 | + | ); | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | function StripeLink({ url }: { url: string | null }) { | |
| 206 | + | const safe = safeUrl(url); | |
| 207 | + | if (!safe) return <span className="text-faint">—</span>; | |
| 208 | + | return ( | |
| 209 | + | <a href={safe} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-merged hover:underline"> | |
| 210 | + | Stripe | |
| 211 | + | <ExternalLink size={12} /> | |
| 212 | + | </a> | |
| 213 | + | ); | |
| 214 | + | } |
| 117 | 117 | hint="Sent and not yet paid" | |
| 118 | 118 | /> | |
| 119 | 119 | <AttentionCard | |
| 120 | − | to={reachOutHref({})} | |
| 120 | + | to={reachOutHref({ due: true })} | |
| 121 | 121 | icon={<CalendarClock size={15} />} | |
| 122 | 122 | label="Follow-ups due" | |
| 123 | 123 | value={overview.followUpsDue} |
| 1 | − | import { ChevronRight } from "lucide-react"; | |
| 1 | + | import { CalendarClock, ChevronRight } from "lucide-react"; | |
| 2 | 2 | import { Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { AdminOwner, Signal } from "@g1t/contracts"; | |
| 6 | 6 | import type { Route } from "./+types/reach-out"; | |
| 7 | 7 | import { Owners } from "~/components/billing"; | |
| 8 | 8 | import { SignalBadge, StaffName, StageBadge } from "~/components/sales"; | |
| 9 | − | import { Avatar, EmptyState, Notice, PageHeader } from "~/components/ui"; | |
| 9 | + | import { Avatar, EmptyState, Notice, PageHeader, When } from "~/components/ui"; | |
| 10 | 10 | import { usd } from "~/lib/money"; | |
| 11 | 11 | import { identity, admin } from "~/lib/services.server"; | |
| 12 | 12 | import { settle } from "~/lib/settle"; | |
| 13 | − | import { SIGNAL_KINDS, type Who, bySignalUrgency, countByKind, filterSignals, isSignalKind, parseWho, reachOutHref } from "~/lib/signals"; | |
| 13 | + | import { | |
| 14 | + | SIGNAL_KINDS, | |
| 15 | + | type Who, | |
| 16 | + | bySignalUrgency, | |
| 17 | + | countByKind, | |
| 18 | + | filterSignals, | |
| 19 | + | followUpsDue, | |
| 20 | + | isFollowUpDue, | |
| 21 | + | isSignalKind, | |
| 22 | + | parseWho, | |
| 23 | + | reachOutHref, | |
| 24 | + | today, | |
| 25 | + | } from "~/lib/signals"; | |
| 14 | 26 | import { requireStaff } from "~/lib/staff"; | |
| 15 | 27 | ||
| 16 | 28 | export const meta: Route.MetaFunction = () => [{ title: "Reach out · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 21 | 33 | const rawKind = url.searchParams.get("kind"); | |
| 22 | 34 | const kind = isSignalKind(rawKind) ? rawKind : null; | |
| 23 | 35 | const who = parseWho(url.searchParams.get("who")); | |
| 36 | + | const due = url.searchParams.get("due") === "1"; | |
| 37 | + | const on = today(); | |
| 24 | 38 | ||
| 25 | 39 | // Owners and names come from identity's list: one call, newest 500. | |
| 26 | 40 | const [signals, workspaces] = await Promise.all([settle(admin.signals()), settle(identity.workspaces())]); | |
| 27 | − | const all = signals.ok ? bySignalUrgency(signals.value) : []; | |
| 41 | + | const every = signals.ok ? bySignalUrgency(signals.value) : []; | |
| 42 | + | const dueAll = followUpsDue(every, on); | |
| 43 | + | // Follow-ups due: one line per workspace whose next step is due. | |
| 44 | + | const all = due ? dueAll : every; | |
| 28 | 45 | const people = new Map( | |
| 29 | 46 | (workspaces.ok ? workspaces.value : []).map((workspace) => [workspace.slug, { name: workspace.name, owners: workspace.owners }]), | |
| 30 | 47 | ); | |
| 36 | 53 | })); | |
| 37 | 54 | return { | |
| 38 | 55 | me: staff.email, | |
| 56 | + | on, | |
| 39 | 57 | kind, | |
| 40 | 58 | who, | |
| 59 | + | due, | |
| 60 | + | dueCount: filterSignals(dueAll, { kind: null, who, me: staff.email }).length, | |
| 41 | 61 | rows, | |
| 42 | − | total: all.length, | |
| 43 | − | workspaces: new Set(all.map((signal) => signal.workspace)).size, | |
| 62 | + | total: every.length, | |
| 63 | + | workspaces: new Set(every.map((signal) => signal.workspace)).size, | |
| 44 | 64 | counts: countByKind(forWho), | |
| 45 | 65 | whoCounts: { | |
| 46 | 66 | all: all.length, | |
| 79 | 99 | } | |
| 80 | 100 | ||
| 81 | 101 | export default function ReachOut({ loaderData }: Route.ComponentProps) { | |
| 82 | − | const { me, kind, who, rows, total, workspaces, counts, whoCounts, error } = loaderData; | |
| 102 | + | const { me, on, kind, who, due, dueCount, rows, total, workspaces, counts, whoCounts, error } = loaderData; | |
| 83 | 103 | const forWho = Object.values(counts).reduce((sum, count) => sum + count, 0); | |
| 84 | 104 | ||
| 85 | 105 | return ( | |
| 109 | 129 | <div className="flex flex-wrap items-center gap-1.5"> | |
| 110 | 130 | <span className="mr-1 w-10 text-xs text-faint">Whose</span> | |
| 111 | 131 | {WHO.map((option) => ( | |
| 112 | − | <Pill key={option.who} to={reachOutHref({ kind, who: option.who })} active={who === option.who} count={whoCounts[option.who]}> | |
| 132 | + | <Pill key={option.who} to={reachOutHref({ kind, who: option.who, due })} active={who === option.who} count={whoCounts[option.who]}> | |
| 113 | 133 | {option.label} | |
| 114 | 134 | </Pill> | |
| 115 | 135 | ))} | |
| 136 | + | <span className="mx-1 h-4 w-px bg-line" aria-hidden="true" /> | |
| 137 | + | <Pill to={reachOutHref({ kind, who, due: !due })} active={due} count={dueCount}> | |
| 138 | + | <CalendarClock size={12} /> | |
| 139 | + | Follow-ups due | |
| 140 | + | </Pill> | |
| 116 | 141 | </div> | |
| 117 | 142 | <div className="flex flex-wrap items-center gap-1.5"> | |
| 118 | 143 | <span className="mr-1 w-10 text-xs text-faint">Why</span> | |
| 119 | − | <Pill to={reachOutHref({ who })} active={kind == null} count={forWho}> | |
| 144 | + | <Pill to={reachOutHref({ who, due })} active={kind == null} count={forWho}> | |
| 120 | 145 | Any | |
| 121 | 146 | </Pill> | |
| 122 | 147 | {SIGNAL_KINDS.map((entry) => ( | |
| 123 | − | <Pill key={entry.kind} to={reachOutHref({ kind: entry.kind, who })} active={kind === entry.kind} count={counts[entry.kind] ?? 0}> | |
| 148 | + | <Pill key={entry.kind} to={reachOutHref({ kind: entry.kind, who, due })} active={kind === entry.kind} count={counts[entry.kind] ?? 0}> | |
| 124 | 149 | {entry.label} | |
| 125 | 150 | </Pill> | |
| 126 | 151 | ))} | |
| 130 | 155 | {rows.length === 0 ? ( | |
| 131 | 156 | <div className="mt-5"> | |
| 132 | 157 | <EmptyState title={total === 0 ? "Nobody needs a word right now" : "None match"}> | |
| 133 | − | {total === 0 | |
| 158 | + | {due && rows.length === 0 && total > 0 | |
| 159 | + | ? "No next steps are due. Set one on a workspace's Sales section and it shows up here on its day." | |
| 160 | + | : total === 0 | |
| 134 | 161 | ? "Workspaces show up here when they stop, come close to their limit, are declined, grow, or pay for the first time." | |
| 135 | 162 | : who === "mine" | |
| 136 | 163 | ? "No signals for workspaces you own. Take one from Unassigned by setting yourself as its owner on its Sales section." | |
| 157 | 184 | <StageBadge stage={row.signal.stage} /> | |
| 158 | 185 | <StaffName email={row.signal.owner} me={me} /> | |
| 159 | 186 | </div> | |
| 187 | + | <NextStep signal={row.signal} on={on} /> | |
| 160 | 188 | </Link> | |
| 161 | 189 | </li> | |
| 162 | 190 | ))} | |
| 170 | 198 | <th className="px-4 py-2.5 font-medium">Workspace</th> | |
| 171 | 199 | <th className="px-4 py-2.5 font-medium">Why</th> | |
| 172 | 200 | <th className="px-4 py-2.5 text-right font-medium">Figure</th> | |
| 173 | − | <th className="px-4 py-2.5 font-medium">Stage</th> | |
| 201 | + | <th className="px-4 py-2.5 font-medium">Stage · next step</th> | |
| 174 | 202 | <th className="px-4 py-2.5 font-medium">Owner at g1t</th> | |
| 175 | 203 | <th className="w-8 px-2 py-2.5" /> | |
| 176 | 204 | </tr> | |
| 188 | 216 | <p className="mt-1 max-w-md text-sm text-muted">{row.signal.detail}</p> | |
| 189 | 217 | </td> | |
| 190 | 218 | <td className="tabular px-4 py-3 text-right whitespace-nowrap text-fg-soft">{usd(row.signal.valueMicros)}</td> | |
| 191 | − | <td className="px-4 py-3"> | |
| 219 | + | <td className="max-w-56 px-4 py-3"> | |
| 192 | 220 | <StageBadge stage={row.signal.stage} /> | |
| 221 | + | <NextStep signal={row.signal} on={on} /> | |
| 193 | 222 | </td> | |
| 194 | 223 | <td className="max-w-48 px-4 py-3 text-xs"> | |
| 195 | 224 | <StaffName email={row.signal.owner} me={me} /> | |
| 228 | 257 | </div> | |
| 229 | 258 | ); | |
| 230 | 259 | } | |
| 260 | + | ||
| 261 | + | /** The workspace's next step, and its day: amber when it is due. */ | |
| 262 | + | function NextStep({ signal, on }: { signal: Signal; on: string }) { | |
| 263 | + | if (!signal.nextStep && !signal.nextAt) return null; | |
| 264 | + | const due = isFollowUpDue(signal, on); | |
| 265 | + | return ( | |
| 266 | + | <p className={`mt-1.5 flex items-start gap-1.5 text-xs ${due ? "text-warn" : "text-muted"}`}> | |
| 267 | + | <CalendarClock size={12} className="mt-0.5 shrink-0" /> | |
| 268 | + | <span className="min-w-0"> | |
| 269 | + | {signal.nextStep ?? "Next step"} | |
| 270 | + | {signal.nextAt && ( | |
| 271 | + | <span className={due ? "text-warn" : "text-faint"}> | |
| 272 | + | {" · "} | |
| 273 | + | {due ? (signal.nextAt.slice(0, 10) < on ? "overdue since " : "due ") : "on "} | |
| 274 | + | <When at={`${signal.nextAt.slice(0, 10)}T00:00:00Z`} /> | |
| 275 | + | </span> | |
| 276 | + | )} | |
| 277 | + | </span> | |
| 278 | + | </p> | |
| 279 | + | ); | |
| 280 | + | } |