Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
1 commit
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 | + | } |