Skip to content

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

306 linesCodeBlameRaw
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 className={`flex gap-2.5 rounded-md border px-3 py-2.5 text-sm ${isDue(record.nextAt) ? "border-warn/40 bg-warn/8" : "border-line bg-bg"}`}>
91 <CalendarClock size={16} className={`mt-0.5 shrink-0 ${isDue(record.nextAt) ? "text-warn" : "text-faint"}`} />
92 <div className="min-w-0">
93 <p className="text-fg-soft">{record.nextStep}</p>
94 <p className="mt-0.5 text-xs text-faint">
95 {record.nextAt ? (
96 <>
97 {isDue(record.nextAt) ? "Due " : "On "}
98 <When at={`${record.nextAt.slice(0, 10)}T00:00:00Z`} />
99 </>
100 ) : (
101 "No date"
102 )}
103 {" · "}
104 <StaffName email={record.owner} me={me} />
105 </p>
106 </div>
107 </div>
108 )}
109 <div className="grid gap-4 sm:grid-cols-2">
110 <Field label="Stage">
111 <Select name="stage" defaultValue={values?.stage ?? record?.stage ?? "none"}>
112 {STAGES.map((entry) => (
113 <option key={entry.stage} value={entry.stage}>
114 {entry.label}
115 </option>
116 ))}
117 </Select>
118 </Field>
119 <Field label="Owner" hint="Who on the team has it. Blank: nobody.">
120 <Input name="owner" type="email" placeholder="you@g1t.sh" defaultValue={values?.owner ?? record?.owner ?? me} className="font-mono text-xs" />
121 </Field>
122 </div>
123 <div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_10rem]">
124 <Field label="Next step">
125 <Input
126 name="nextStep"
127 maxLength={200}
128 placeholder="e.g. Call the owner about an enterprise plan"
129 defaultValue={values?.nextStep ?? record?.nextStep ?? ""}
130 />
131 </Field>
132 <Field label="By" hint="UTC.">
133 <Input type="date" name="nextAt" defaultValue={values?.nextAt ?? record?.nextAt?.slice(0, 10) ?? ""} />
134 </Field>
135 </div>
136 <div className="flex justify-end">
137 <Button type="submit" variant="quiet">
138 <Save size={14} />
139 Save
140 </Button>
141 </div>
142 </form>
143
144 {/* Notes */}
145 <div>
146 <form method="post" action={`${pathname}#notes`} id="notes" className="scroll-mt-20 space-y-2">
147 <input type="hidden" name="intent" value="note" />
148 {noteError && <Notice tone="error">{noteError.error}</Notice>}
149 <Field label="Add a note" hint="Calls, emails, what they asked for. Staff only; signed with your email.">
150 <Textarea
151 name="text"
152 rows={3}
153 required
154 maxLength={2000}
155 defaultValue={noteError?.values?.text ?? ""}
156 placeholder="e.g. Spoke to the owner: they want annual billing."
157 />
158 </Field>
159 <div className="flex justify-end">
160 <Button type="submit" variant="quiet">
161 <MessageSquarePlus size={14} />
162 Add note
163 </Button>
164 </div>
165 </form>
166 <NotesList notes={record?.notes ?? []} me={me} />
167 </div>
168 </div>
169 )}
170 </Section>
171 );
172}
173
174function NotesList({ notes, me }: { notes: SalesRecord["notes"]; me: string }) {
175 if (notes.length === 0) return <p className="mt-4 text-sm text-faint">No notes yet.</p>;
176 const sorted = [...notes].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
177 return (
178 <ol className="mt-4 space-y-3">
179 {sorted.map((note) => (
180 <li key={note.id} className="border-l-2 border-accent/40 pl-3">
181 <p className="text-sm wrap-break-word whitespace-pre-line text-fg-soft">{note.text}</p>
182 <p className="mt-1 flex flex-wrap items-center gap-x-2 text-xs text-faint">
183 <StaffName email={note.by} me={me} />
184 <When at={note.createdAt} time />
185 </p>
186 </li>
187 ))}
188 </ol>
189 );
190}
191
192// --- Invoices -----------------------------------------------------------------
193
194const INVOICE_STATUS: Record<string, { label: string; tone: "plain" | "mint" | "warn" | "danger" | "info" }> = {
195 open: { label: "Open", tone: "info" },
196 paid: { label: "Paid", tone: "mint" },
197 failed: { label: "Failed", tone: "danger" },
198 overdue: { label: "Overdue", tone: "danger" },
199 void: { label: "Void", tone: "plain" },
200};
201
202export function InvoiceStatus({ status }: { status: string }) {
203 const { label, tone } = INVOICE_STATUS[status] ?? { label: status, tone: "plain" as const };
204 return <Badge tone={tone}>{label}</Badge>;
205}
206
207/** A link to a page on Stripe; https only. */
208function StripeLink({ url, children }: { url: string | null; children: ReactNode }) {
209 if (!url || !url.startsWith("https://")) return null;
210 return (
211 <a href={url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-accent hover:underline">
212 {children}
213 </a>
214 );
215}
216
217const REASON: Record<string, string> = { month: "Monthly", threshold: "Near the limit" };
218
219export function WorkspaceInvoicesSection({ invoices, unavailable }: { invoices: WorkspaceInvoice[]; unavailable: string | null }) {
220 const open = invoices.filter((invoice) => invoice.status === "open" || invoice.status === "failed");
221 const owed = open.reduce((sum, invoice) => sum + invoice.amountMicros, 0);
222 return (
223 <Section
224 id="invoices"
225 title="Invoices"
226 description={
227 invoices.length > 0 && open.length > 0 ? (
228 <>
229 {open.length} not paid, {usd(owed)} in all. Monthly, and each time it is charged near its limit.
230 </>
231 ) : (
232 "Monthly, and each time it is charged near its limit. Charged to the card on file."
233 )
234 }
235 >
236 {unavailable ? (
237 <Notice tone="warn">Billing did not answer for invoices: {unavailable}</Notice>
238 ) : invoices.length === 0 ? (
239 <EmptyState title="No invoices yet" />
240 ) : (
241 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
242 <table className="w-full min-w-xl text-sm">
243 <thead>
244 <tr className="border-b border-line text-left text-xs text-muted">
245 <th className="px-4 py-2 font-medium sm:pl-5">Period</th>
246 <th className="px-4 py-2 font-medium">Status</th>
247 <th className="px-4 py-2 font-medium">Lines</th>
248 <th className="px-4 py-2 text-right font-medium">Amount</th>
249 <th className="px-4 py-2 font-medium sm:pr-5">On Stripe</th>
250 </tr>
251 </thead>
252 <tbody>
253 {invoices.map((invoice) => (
254 <tr key={invoice.invoiceId} className="border-b border-line align-top last:border-0">
255 <td className="px-4 py-2.5 sm:pl-5">
256 <p className="text-fg-soft">{invoice.period}</p>
257 <p className="text-xs text-faint">
258 {REASON[invoice.reason] ?? invoice.reason} · <When at={invoice.createdAt} />
259 </p>
260 </td>
261 <td className="px-4 py-2.5">
262 <InvoiceStatus status={invoice.status} />
263 </td>
264 <td className="px-4 py-2.5">
265 {invoice.lines.length === 0 ? (
266 <span className="text-xs text-faint">—</span>
267 ) : (
268 <details className="group">
269 <summary className="cursor-pointer list-none text-xs text-muted select-none hover:text-fg [&::-webkit-details-marker]:hidden">
270 {invoice.lines.length} line{invoice.lines.length === 1 ? "" : "s"}
271 <span className="text-faint group-open:hidden"> · show</span>
272 </summary>
273 <ul className="mt-1.5 space-y-0.5 text-xs">
274 {invoice.lines.map((line, index) => (
275 <li key={index} className="tabular flex justify-between gap-3">
276 <span className="text-fg-soft">{line.description}</span>
277 <span className="text-faint">{usd(line.amountMicros)}</span>
278 </li>
279 ))}
280 </ul>
281 </details>
282 )}
283 </td>
284 <td className="tabular px-4 py-2.5 text-right whitespace-nowrap">{usd(invoice.amountMicros)}</td>
285 <td className="px-4 py-2.5 text-xs sm:pr-5">
286 <span className="flex flex-wrap gap-x-3 gap-y-1">
287 <StripeLink url={invoice.hostedUrl}>
288 Page
289 <ExternalLink size={12} />
290 </StripeLink>
291 <StripeLink url={invoice.pdfUrl}>
292 PDF
293 <FileDown size={12} />
294 </StripeLink>
295 {!invoice.hostedUrl && !invoice.pdfUrl && <span className="text-faint">—</span>}
296 </span>
297 </td>
298 </tr>
299 ))}
300 </tbody>
301 </table>
302 </div>
303 )}
304 </Section>
305 );
306}