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/reach-out.tsx

280 lines11,872 bytesCodeBlame
1import { CalendarClock, ChevronRight } from "lucide-react";
2import { Link } from "react-router";
3
4import type { AdminOwner, Signal } from "@g1t/contracts";
5
6import type { Route } from "./+types/reach-out";
7import { Owners } from "~/components/billing";
8import { SignalBadge, StaffName, StageBadge } from "~/components/sales";
9import { Avatar, EmptyState, Notice, PageHeader, When } from "~/components/ui";
10import { usd } from "~/lib/money";
11import { identity, admin } from "~/lib/services.server";
12import { settle } from "~/lib/settle";
13import {
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";
26import { requireStaff } from "~/lib/staff";
27
28export const meta: Route.MetaFunction = () => [{ title: "Reach out · sudo" }, { name: "robots", content: "noindex, nofollow" }];
29
30export async function loader({ request, context }: Route.LoaderArgs) {
31 const staff = requireStaff(context);
32 const url = new URL(request.url);
33 const rawKind = url.searchParams.get("kind");
34 const kind = isSignalKind(rawKind) ? rawKind : null;
35 const who = parseWho(url.searchParams.get("who"));
36 const due = url.searchParams.get("due") === "1";
37 const on = today();
38
39 // Owners and names come from identity's list: one call, newest 500.
40 const [signals, workspaces] = await Promise.all([settle(admin.signals()), settle(identity.workspaces())]);
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;
45 const people = new Map(
46 (workspaces.ok ? workspaces.value : []).map((workspace) => [workspace.slug, { name: workspace.name, owners: workspace.owners }]),
47 );
48 const forWho = filterSignals(all, { kind: null, who, me: staff.email });
49 const rows = filterSignals(forWho, { kind, who: "all", me: staff.email }).map((signal) => ({
50 signal,
51 name: people.get(signal.workspace)?.name ?? null,
52 owners: people.get(signal.workspace)?.owners ?? ([] as AdminOwner[]),
53 }));
54 return {
55 me: staff.email,
56 on,
57 kind,
58 who,
59 due,
60 dueCount: filterSignals(dueAll, { kind: null, who, me: staff.email }).length,
61 rows,
62 total: every.length,
63 workspaces: new Set(every.map((signal) => signal.workspace)).size,
64 counts: countByKind(forWho),
65 whoCounts: {
66 all: all.length,
67 unassigned: filterSignals(all, { kind: null, who: "unassigned", me: staff.email }).length,
68 mine: filterSignals(all, { kind: null, who: "mine", me: staff.email }).length,
69 },
70 error: signals.ok ? null : signals.error,
71 };
72}
73
74const WHO: { who: Who; label: string }[] = [
75 { who: "all", label: "Everyone's" },
76 { who: "unassigned", label: "Unassigned" },
77 { who: "mine", label: "Mine" },
78];
79
80function Pill({ to, active, children, count }: { to: string; active: boolean; children: React.ReactNode; count?: number }) {
81 return (
82 <Link
83 to={to}
84 aria-current={active ? "page" : undefined}
85 className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors ${
86 active ? "border-merged/50 bg-merged/10 text-merged" : "border-line text-muted hover:border-line-strong hover:text-fg"
87 }`}
88 >
89 {children}
90 {count != null && <span className={`tabular ${active ? "text-merged/80" : "text-faint"}`}>{count}</span>}
91 </Link>
92 );
93}
94
95type Row = { signal: Signal; name: string | null; owners: AdminOwner[] };
96
97function rowHref(signal: Signal) {
98 return `/workspaces/${encodeURIComponent(signal.workspace)}#sales`;
99}
100
101export default function ReachOut({ loaderData }: Route.ComponentProps) {
102 const { me, on, kind, who, due, dueCount, rows, total, workspaces, counts, whoCounts, error } = loaderData;
103 const forWho = Object.values(counts).reduce((sum, count) => sum + count, 0);
104
105 return (
106 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
107 <PageHeader
108 title="Reach out"
109 description={
110 <>
111 Workspaces worth a word now, most urgent first: stopped, declined, close to their limit, spending more, or newly steady.
112 {total > 0 && (
113 <>
114 {" "}
115 {total} signal{total === 1 ? "" : "s"} across {workspaces} workspace{workspaces === 1 ? "" : "s"}.
116 </>
117 )}
118 </>
119 }
120 />
121
122 {error && (
123 <div className="mt-6">
124 <Notice tone="warn">Billing did not answer for signals: {error}</Notice>
125 </div>
126 )}
127
128 <nav aria-label="Filters" className="mt-6 space-y-2.5">
129 <div className="flex flex-wrap items-center gap-1.5">
130 <span className="mr-1 w-10 text-xs text-faint">Whose</span>
131 {WHO.map((option) => (
132 <Pill key={option.who} to={reachOutHref({ kind, who: option.who, due })} active={who === option.who} count={whoCounts[option.who]}>
133 {option.label}
134 </Pill>
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>
141 </div>
142 <div className="flex flex-wrap items-center gap-1.5">
143 <span className="mr-1 w-10 text-xs text-faint">Why</span>
144 <Pill to={reachOutHref({ who, due })} active={kind == null} count={forWho}>
145 Any
146 </Pill>
147 {SIGNAL_KINDS.map((entry) => (
148 <Pill key={entry.kind} to={reachOutHref({ kind: entry.kind, who, due })} active={kind === entry.kind} count={counts[entry.kind] ?? 0}>
149 {entry.label}
150 </Pill>
151 ))}
152 </div>
153 </nav>
154
155 {rows.length === 0 ? (
156 <div className="mt-5">
157 <EmptyState title={total === 0 ? "Nobody needs a word right now" : "None match"}>
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
161 ? "Workspaces show up here when they stop, come close to their limit, are declined, grow, or pay for the first time."
162 : who === "mine"
163 ? "No signals for workspaces you own. Take one from Unassigned by setting yourself as its owner on its Sales section."
164 : "Try another filter."}
165 </EmptyState>
166 </div>
167 ) : (
168 <>
169 {/* Phones: one card per signal. */}
170 <ul className="mt-5 space-y-2 md:hidden">
171 {rows.map((row, index) => (
172 <li key={`${row.signal.workspace}-${row.signal.kind}-${index}`}>
173 <Link to={rowHref(row.signal)} className="block rounded-lg border border-line bg-surface p-4 transition-colors hover:border-line-strong">
174 <div className="flex items-start justify-between gap-3">
175 <WorkspaceCell row={row} />
176 <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint" />
177 </div>
178 <div className="mt-3 flex flex-wrap items-center gap-2">
179 <SignalBadge kind={row.signal.kind} />
180 <span className="tabular text-sm text-fg-soft">{usd(row.signal.valueMicros)}</span>
181 </div>
182 <p className="mt-1.5 text-sm text-muted">{row.signal.detail}</p>
183 <div className="mt-3 flex flex-wrap items-center gap-2 border-t border-line pt-3 text-xs">
184 <StageBadge stage={row.signal.stage} />
185 <StaffName email={row.signal.owner} me={me} />
186 </div>
187 <NextStep signal={row.signal} on={on} />
188 </Link>
189 </li>
190 ))}
191 </ul>
192
193 {/* Wider screens: a table. */}
194 <div className="mt-5 hidden overflow-x-auto rounded-lg border border-line md:block">
195 <table className="w-full text-sm">
196 <thead>
197 <tr className="border-b border-line bg-surface text-left text-xs text-muted">
198 <th className="px-4 py-2.5 font-medium">Workspace</th>
199 <th className="px-4 py-2.5 font-medium">Why</th>
200 <th className="px-4 py-2.5 text-right font-medium">Figure</th>
201 <th className="px-4 py-2.5 font-medium">Stage · next step</th>
202 <th className="px-4 py-2.5 font-medium">Owner at g1t</th>
203 <th className="w-8 px-2 py-2.5" />
204 </tr>
205 </thead>
206 <tbody>
207 {rows.map((row, index) => (
208 <tr key={`${row.signal.workspace}-${row.signal.kind}-${index}`} className="border-b border-line align-top last:border-0 hover:bg-surface/60">
209 <td className="max-w-64 px-4 py-3">
210 <Link to={rowHref(row.signal)} className="group block">
211 <WorkspaceCell row={row} />
212 </Link>
213 </td>
214 <td className="px-4 py-3">
215 <SignalBadge kind={row.signal.kind} />
216 <p className="mt-1 max-w-md text-sm text-muted">{row.signal.detail}</p>
217 </td>
218 <td className="tabular px-4 py-3 text-right whitespace-nowrap text-fg-soft">{usd(row.signal.valueMicros)}</td>
219 <td className="max-w-56 px-4 py-3">
220 <StageBadge stage={row.signal.stage} />
221 <NextStep signal={row.signal} on={on} />
222 </td>
223 <td className="max-w-48 px-4 py-3 text-xs">
224 <StaffName email={row.signal.owner} me={me} />
225 </td>
226 <td className="px-2 py-3">
227 <Link to={rowHref(row.signal)} aria-label={`Open ${row.signal.workspace}'s sales`} className="block text-faint hover:text-fg">
228 <ChevronRight size={16} />
229 </Link>
230 </td>
231 </tr>
232 ))}
233 </tbody>
234 </table>
235 </div>
236 </>
237 )}
238 </main>
239 );
240}
241
242function WorkspaceCell({ row }: { row: Row }) {
243 return (
244 <div className="flex min-w-0 items-start gap-2.5">
245 <span className="mt-0.5">
246 <Avatar name={row.signal.workspace} size={22} />
247 </span>
248 <div className="min-w-0">
249 <p className="truncate font-medium group-hover:underline group-hover:underline-offset-4">{row.name ?? row.signal.workspace}</p>
250 <p className="truncate font-mono text-xs text-faint">{row.signal.workspace}</p>
251 {row.owners.length > 0 && (
252 <div className="mt-1.5 text-xs">
253 <Owners owners={row.owners} compact />
254 </div>
255 )}
256 </div>
257 </div>
258 );
259}
260
261/** The workspace's next step, and its day: amber when it is due. */
262function 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}