flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

214 lines9,389 bytesCodeBlame
1import { ExternalLink } from "lucide-react";
2import { Link } from "react-router";
3
4import type { InvoiceSummary } from "@g1t/contracts";
5
6import type { Route } from "./+types/invoices";
7import { InvoiceStatus } from "~/components/sales";
8import { Avatar, Badge, Button, EmptyState, Input, Notice, PageHeader, Select, Stat, When } from "~/components/ui";
9import { monthLong } from "~/lib/chart";
10import { INVOICE_STATUSES, accountPath, invoiceTotals, parseInvoiceStatus, parseMonth, safeUrl } from "~/lib/ledgers";
11import { usd } from "~/lib/money";
12import { admin } from "~/lib/services.server";
13import { settle } from "~/lib/settle";
14import { requireStaff } from "~/lib/staff";
15
16export const meta: Route.MetaFunction = () => [{ title: "Invoices · sudo" }, { name: "robots", content: "noindex, nofollow" }];
17
18/** The most billing returns in one list. */
19const MOST = 200;
20
21export 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
38const STATUS_LABEL: Record<string, string> = { open: "Open", overdue: "Overdue", failed: "Failed", paid: "Paid", void: "Void" };
39const REASON: Record<string, string> = { month: "Monthly", threshold: "Near the limit", manual: "Sent by staff" };
40
41export 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
168function 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
192function 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
205function 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}