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/components/sales.tsx

298 lines12,618 bytesCodeBlame
1/**
2 * Sales on a workspace's page: where it stands, who on the team has it,
3 * the next step, and notes; and its invoices from g1t. Plain forms.
4 */
5import { CalendarClock, ExternalLink, FileDown, MessageSquarePlus, Save, UserRound } from "lucide-react";
6import type { ReactNode } from "react";
7
8import type { SalesRecord, WorkspaceInvoice } from "@g1t/contracts";
9
10import { Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui";
11import { usd } from "~/lib/money";
12import type { SectionError } from "~/lib/review";
13import { STAGES, signalMeta, stageMeta } from "~/lib/signals";
14
15export function SignalBadge({ kind }: { kind: string }) {
16 const meta = signalMeta(kind);
17 return (
18 <span title={meta.about || undefined}>
19 <Badge tone={meta.tone}>{meta.label}</Badge>
20 </span>
21 );
22}
23
24export function StageBadge({ stage }: { stage: string | null | undefined }) {
25 const meta = stageMeta(stage);
26 return <Badge tone={meta.tone}>{meta.label}</Badge>;
27}
28
29/** A staff member, as "you" when it is the one looking. */
30export function StaffName({ email, me }: { email: string | null | undefined; me: string }) {
31 if (!email) return <span className="text-faint">Unassigned</span>;
32 const mine = email.toLowerCase() === me.toLowerCase();
33 return (
34 <span title={email} className="inline-flex min-w-0 items-center gap-1">
35 <UserRound size={12} className="shrink-0 text-faint" />
36 <span className="truncate font-mono text-xs text-fg-soft">{mine ? "you" : email}</span>
37 </span>
38 );
39}
40
41/** Whether a follow-up day (YYYY-MM-DD) is today or past, UTC. */
42function isDue(day: string | null, today = new Date().toISOString().slice(0, 10)): boolean {
43 return day != null && day.slice(0, 10) <= today;
44}
45
46export function SalesSection({
47 sales,
48 unavailable,
49 me,
50 pathname,
51 salesError,
52 noteError,
53 done,
54}: {
55 sales: SalesRecord | null;
56 unavailable: string | null;
57 me: string;
58 pathname: string;
59 salesError: SectionError;
60 noteError: SectionError;
61 done: string | null;
62}) {
63 const values = salesError?.values;
64 const record = sales;
65 return (
66 <Section
67 id="sales"
68 title="Sales"
69 description={
70 record?.updatedAt ? (
71 <>
72 Where it stands with g1t, and what the team is doing about it. Updated <When at={record.updatedAt} time />.
73 </>
74 ) : (
75 "Where it stands with g1t, and what the team is doing about it."
76 )
77 }
78 actions={record && <StageBadge stage={record.stage} />}
79 >
80 {unavailable ? (
81 <Notice tone="warn">Billing did not answer for sales records: {unavailable}</Notice>
82 ) : (
83 <div className="grid gap-6 lg:grid-cols-2">
84 {/* Stage, owner and next step */}
85 <form method="post" action={`${pathname}#sales`} className="space-y-4">
86 <input type="hidden" name="intent" value="sales" />
87 {done && <Notice tone="ok">{done}</Notice>}
88 {salesError && <Notice tone="error">{salesError.error}</Notice>}
89 {record?.nextStep && (
90 <div
91 className={`flex gap-2.5 rounded-md border px-3 py-2.5 text-sm ${
92 isDue(record.nextAt) ? "border-warn/40 bg-warn/8" : "border-line bg-bg"
93 }`}
94 >
95 <CalendarClock size={16} className={`mt-0.5 shrink-0 ${isDue(record.nextAt) ? "text-warn" : "text-faint"}`} />
96 <div className="min-w-0">
97 <p className="text-fg-soft">{record.nextStep}</p>
98 <p className="mt-0.5 text-xs text-faint">
99 {record.nextAt ? (
100 <>
101 {isDue(record.nextAt) ? "Due " : "On "}
102 <When at={`${record.nextAt.slice(0, 10)}T00:00:00Z`} />
103 </>
104 ) : (
105 "No date"
106 )}
107 {" · "}
108 <StaffName email={record.owner} me={me} />
109 </p>
110 </div>
111 </div>
112 )}
113 <div className="grid gap-4 sm:grid-cols-2">
114 <Field label="Stage">
115 <Select name="stage" defaultValue={values?.stage ?? record?.stage ?? "none"}>
116 {STAGES.map((entry) => (
117 <option key={entry.stage} value={entry.stage}>
118 {entry.label}
119 </option>
120 ))}
121 </Select>
122 </Field>
123 <Field label="Owner" hint="Who on the team has it. Blank: nobody.">
124 <Input name="owner" type="email" placeholder="you@g1t.sh" defaultValue={values?.owner ?? record?.owner ?? me} className="font-mono text-xs" />
125 </Field>
126 </div>
127 <div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_10rem]">
128 <Field label="Next step">
129 <Input name="nextStep" maxLength={200} placeholder="e.g. Call the owner about an enterprise plan" defaultValue={values?.nextStep ?? record?.nextStep ?? ""} />
130 </Field>
131 <Field label="By" hint="UTC.">
132 <Input type="date" name="nextAt" defaultValue={values?.nextAt ?? record?.nextAt?.slice(0, 10) ?? ""} />
133 </Field>
134 </div>
135 <div className="flex justify-end">
136 <Button type="submit" variant="quiet">
137 <Save size={14} />
138 Save
139 </Button>
140 </div>
141 </form>
142
143 {/* Notes */}
144 <div>
145 <form method="post" action={`${pathname}#notes`} id="notes" className="scroll-mt-20 space-y-2">
146 <input type="hidden" name="intent" value="note" />
147 {noteError && <Notice tone="error">{noteError.error}</Notice>}
148 <Field label="Add a note" hint="Calls, emails, what they asked for. Staff only; signed with your email.">
149 <Textarea name="text" rows={3} required maxLength={2000} defaultValue={noteError?.values?.text ?? ""} placeholder="e.g. Spoke to the owner: they want annual billing." />
150 </Field>
151 <div className="flex justify-end">
152 <Button type="submit" variant="quiet">
153 <MessageSquarePlus size={14} />
154 Add note
155 </Button>
156 </div>
157 </form>
158 <NotesList notes={record?.notes ?? []} me={me} />
159 </div>
160 </div>
161 )}
162 </Section>
163 );
164}
165
166function NotesList({ notes, me }: { notes: SalesRecord["notes"]; me: string }) {
167 if (notes.length === 0) return <p className="mt-4 text-sm text-faint">No notes yet.</p>;
168 const sorted = [...notes].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
169 return (
170 <ol className="mt-4 space-y-3">
171 {sorted.map((note) => (
172 <li key={note.id} className="border-l-2 border-merged/40 pl-3">
173 <p className="text-sm break-words whitespace-pre-line text-fg-soft">{note.text}</p>
174 <p className="mt-1 flex flex-wrap items-center gap-x-2 text-xs text-faint">
175 <StaffName email={note.by} me={me} />
176 <When at={note.createdAt} time />
177 </p>
178 </li>
179 ))}
180 </ol>
181 );
182}
183
184// --- Invoices -----------------------------------------------------------------
185
186const INVOICE_STATUS: Record<string, { label: string; tone: "plain" | "mint" | "warn" | "danger" | "info" }> = {
187 open: { label: "Open", tone: "info" },
188 paid: { label: "Paid", tone: "mint" },
189 failed: { label: "Failed", tone: "danger" },
190 overdue: { label: "Overdue", tone: "danger" },
191 void: { label: "Void", tone: "plain" },
192};
193
194export function InvoiceStatus({ status }: { status: string }) {
195 const { label, tone } = INVOICE_STATUS[status] ?? { label: status, tone: "plain" as const };
196 return <Badge tone={tone}>{label}</Badge>;
197}
198
199/** A link to a page on Stripe; https only. */
200function StripeLink({ url, children }: { url: string | null; children: ReactNode }) {
201 if (!url || !url.startsWith("https://")) return null;
202 return (
203 <a href={url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-merged hover:underline">
204 {children}
205 </a>
206 );
207}
208
209const REASON: Record<string, string> = { month: "Monthly", threshold: "Near the limit" };
210
211export function WorkspaceInvoicesSection({ invoices, unavailable }: { invoices: WorkspaceInvoice[]; unavailable: string | null }) {
212 const open = invoices.filter((invoice) => invoice.status === "open" || invoice.status === "failed");
213 const owed = open.reduce((sum, invoice) => sum + invoice.amountMicros, 0);
214 return (
215 <Section
216 id="invoices"
217 title="Invoices"
218 description={
219 invoices.length > 0 && open.length > 0 ? (
220 <>
221 {open.length} not paid, {usd(owed)} in all. Monthly, and each time it is charged near its limit.
222 </>
223 ) : (
224 "Monthly, and each time it is charged near its limit. Charged to the card on file."
225 )
226 }
227 >
228 {unavailable ? (
229 <Notice tone="warn">Billing did not answer for invoices: {unavailable}</Notice>
230 ) : invoices.length === 0 ? (
231 <EmptyState title="No invoices yet" />
232 ) : (
233 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
234 <table className="w-full min-w-xl text-sm">
235 <thead>
236 <tr className="border-b border-line text-left text-xs text-muted">
237 <th className="px-4 py-2 font-medium sm:pl-5">Period</th>
238 <th className="px-4 py-2 font-medium">Status</th>
239 <th className="px-4 py-2 font-medium">Lines</th>
240 <th className="px-4 py-2 text-right font-medium">Amount</th>
241 <th className="px-4 py-2 font-medium sm:pr-5">On Stripe</th>
242 </tr>
243 </thead>
244 <tbody>
245 {invoices.map((invoice) => (
246 <tr key={invoice.invoiceId} className="border-b border-line align-top last:border-0">
247 <td className="px-4 py-2.5 sm:pl-5">
248 <p className="text-fg-soft">{invoice.period}</p>
249 <p className="text-xs text-faint">
250 {REASON[invoice.reason] ?? invoice.reason} · <When at={invoice.createdAt} />
251 </p>
252 </td>
253 <td className="px-4 py-2.5">
254 <InvoiceStatus status={invoice.status} />
255 </td>
256 <td className="px-4 py-2.5">
257 {invoice.lines.length === 0 ? (
258 <span className="text-xs text-faint">—</span>
259 ) : (
260 <details className="group">
261 <summary className="cursor-pointer list-none text-xs text-muted select-none hover:text-fg [&::-webkit-details-marker]:hidden">
262 {invoice.lines.length} line{invoice.lines.length === 1 ? "" : "s"}
263 <span className="text-faint group-open:hidden"> · show</span>
264 </summary>
265 <ul className="mt-1.5 space-y-0.5 text-xs">
266 {invoice.lines.map((line, index) => (
267 <li key={index} className="tabular flex justify-between gap-3">
268 <span className="text-fg-soft">{line.description}</span>
269 <span className="text-faint">{usd(line.amountMicros)}</span>
270 </li>
271 ))}
272 </ul>
273 </details>
274 )}
275 </td>
276 <td className="tabular px-4 py-2.5 text-right whitespace-nowrap">{usd(invoice.amountMicros)}</td>
277 <td className="px-4 py-2.5 text-xs sm:pr-5">
278 <span className="flex flex-wrap gap-x-3 gap-y-1">
279 <StripeLink url={invoice.hostedUrl}>
280 Page
281 <ExternalLink size={12} />
282 </StripeLink>
283 <StripeLink url={invoice.pdfUrl}>
284 PDF
285 <FileDown size={12} />
286 </StripeLink>
287 {!invoice.hostedUrl && !invoice.pdfUrl && <span className="text-faint">—</span>}
288 </span>
289 </td>
290 </tr>
291 ))}
292 </tbody>
293 </table>
294 </div>
295 )}
296 </Section>
297 );
298}