Invoices.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. import { useState, useEffect, useCallback } from "react";
  2. import type { Spec } from "@json-render/react";
  3. import {
  4. Box,
  5. ContextView,
  6. Button,
  7. TextField,
  8. Spinner,
  9. } from "@stripe/ui-extension-sdk/ui";
  10. import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
  11. import BrandIcon from "./brand_icon.svg";
  12. import { API_GENERATE_URL } from "../lib/config";
  13. import { stripeCatalog, StripeRenderer } from "../lib/render";
  14. import { executeAction } from "../lib/render/catalog/actions";
  15. // =============================================================================
  16. // Dynamic Spec Creation
  17. // =============================================================================
  18. function createInvoicesSpec(data: Record<string, unknown>): Spec {
  19. const invoices = data.invoices as
  20. | {
  21. data?: Array<{
  22. id: string;
  23. invoiceNumber: string;
  24. amount: number;
  25. status: string;
  26. dueDate: string;
  27. customerEmail: string;
  28. formattedAmount: string;
  29. }>;
  30. total?: number;
  31. outstanding?: string;
  32. paid?: string;
  33. overdue?: string;
  34. hasMore?: boolean;
  35. }
  36. | undefined;
  37. const invoicesList = invoices?.data ?? [];
  38. const elements: Spec["elements"] = {
  39. root: {
  40. type: "Stack",
  41. props: { direction: "vertical", gap: "large" },
  42. children: [
  43. "heading",
  44. "metrics",
  45. "alert",
  46. "filters",
  47. "list",
  48. "pagination",
  49. ],
  50. },
  51. heading: {
  52. type: "Heading",
  53. props: { text: "Invoices", size: "xlarge" },
  54. children: [],
  55. },
  56. metrics: {
  57. type: "Stack",
  58. props: { direction: "horizontal", gap: "medium" },
  59. children: ["outstandingMetric", "paidMetric", "overdueMetric"],
  60. },
  61. outstandingMetric: {
  62. type: "Metric",
  63. props: {
  64. label: "Outstanding",
  65. value: invoices?.outstanding ?? "$0",
  66. changeType: "neutral",
  67. },
  68. children: [],
  69. },
  70. paidMetric: {
  71. type: "Metric",
  72. props: {
  73. label: "Paid",
  74. value: invoices?.paid ?? "$0",
  75. changeType: "positive",
  76. },
  77. children: [],
  78. },
  79. overdueMetric: {
  80. type: "Metric",
  81. props: {
  82. label: "Overdue",
  83. value: invoices?.overdue ?? "$0",
  84. changeType: invoices?.overdue !== "$0.00" ? "negative" : "neutral",
  85. },
  86. children: [],
  87. },
  88. alert: {
  89. type: "Banner",
  90. props: {
  91. title:
  92. invoices?.overdue !== "$0.00"
  93. ? `You have ${invoices?.overdue} in overdue invoices`
  94. : "All invoices are up to date",
  95. type: invoices?.overdue !== "$0.00" ? "caution" : "default",
  96. },
  97. children: [],
  98. },
  99. filters: {
  100. type: "Stack",
  101. props: { direction: "horizontal", gap: "small" },
  102. children: ["refreshBtn", "createBtn", "exportBtn"],
  103. },
  104. refreshBtn: {
  105. type: "Button",
  106. props: { label: "Refresh", action: "refreshInvoices", type: "secondary" },
  107. children: [],
  108. },
  109. createBtn: {
  110. type: "Button",
  111. props: {
  112. label: "Create Invoice",
  113. action: "openDashboard",
  114. actionParams: { page: "invoices" },
  115. type: "primary",
  116. },
  117. children: [],
  118. },
  119. exportBtn: {
  120. type: "Button",
  121. props: {
  122. label: "Export CSV",
  123. action: "exportData",
  124. actionParams: { format: "csv", dataType: "invoices" },
  125. type: "secondary",
  126. },
  127. children: [],
  128. },
  129. list: {
  130. type: "Stack",
  131. props: { direction: "vertical", gap: "small" },
  132. children: invoicesList.slice(0, 10).map((_, i) => `invoice${i}`),
  133. },
  134. pagination: {
  135. type: "Stack",
  136. props: { direction: "horizontal", gap: "small" },
  137. children: invoices?.hasMore ? ["loadMore"] : [],
  138. },
  139. loadMore: {
  140. type: "Button",
  141. props: {
  142. label: "Load More",
  143. action: "fetchInvoices",
  144. actionParams: { limit: 10 },
  145. type: "secondary",
  146. },
  147. children: [],
  148. },
  149. };
  150. invoicesList.slice(0, 10).forEach((inv, i) => {
  151. elements[`invoice${i}`] = {
  152. type: "InvoiceCard",
  153. props: {
  154. invoiceNumber: inv.invoiceNumber,
  155. amount: inv.amount,
  156. currency: "usd",
  157. status: inv.status as
  158. | "draft"
  159. | "open"
  160. | "paid"
  161. | "void"
  162. | "uncollectible",
  163. dueDate: inv.dueDate,
  164. customerEmail: inv.customerEmail,
  165. },
  166. children: [],
  167. };
  168. });
  169. return { root: "root", elements };
  170. }
  171. // =============================================================================
  172. // Component
  173. // =============================================================================
  174. const Invoices = (_props: ExtensionContextValue) => {
  175. const [data, setState] = useState<Record<string, unknown>>({});
  176. const [spec, setSpec] = useState<Spec | null>(null);
  177. const [loading, setLoading] = useState(true);
  178. const [prompt, setPrompt] = useState("");
  179. const [generating, setGenerating] = useState(false);
  180. const handleSetState = useCallback(
  181. (updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
  182. setState((prev) => updater(prev));
  183. },
  184. [],
  185. );
  186. useEffect(() => {
  187. const loadData = async () => {
  188. setLoading(true);
  189. await executeAction("fetchInvoices", { limit: 10 }, handleSetState, {});
  190. setLoading(false);
  191. };
  192. loadData();
  193. }, [handleSetState]);
  194. useEffect(() => {
  195. if (!loading && Object.keys(data).length > 0) {
  196. setSpec(createInvoicesSpec(data));
  197. }
  198. }, [data, loading]);
  199. const handleGenerate = async () => {
  200. if (!prompt.trim()) return;
  201. setGenerating(true);
  202. const systemPrompt = stripeCatalog.prompt();
  203. try {
  204. const response = await fetch(API_GENERATE_URL, {
  205. method: "POST",
  206. headers: { "Content-Type": "application/json" },
  207. body: JSON.stringify({ prompt, systemPrompt }),
  208. });
  209. const result = await response.json();
  210. if (result.spec) {
  211. setSpec(result.spec);
  212. }
  213. } catch (error) {
  214. console.error("Generation error:", error);
  215. setSpec(createInvoicesSpec(data));
  216. }
  217. setGenerating(false);
  218. };
  219. if (loading) {
  220. return (
  221. <ContextView title="Invoices" brandColor="#635BFF" brandIcon={BrandIcon}>
  222. <Box
  223. css={{
  224. stack: "y",
  225. gap: "medium",
  226. alignX: "center",
  227. paddingY: "xlarge",
  228. }}
  229. >
  230. <Spinner size="large" />
  231. <Box css={{ color: "secondary" }}>Loading invoices...</Box>
  232. </Box>
  233. </ContextView>
  234. );
  235. }
  236. return (
  237. <ContextView
  238. title="Invoices"
  239. brandColor="#635BFF"
  240. brandIcon={BrandIcon}
  241. actions={
  242. <Button
  243. type="primary"
  244. onPress={() =>
  245. executeAction(
  246. "openDashboard",
  247. { page: "invoices" },
  248. handleSetState,
  249. data,
  250. )
  251. }
  252. >
  253. Open in Dashboard
  254. </Button>
  255. }
  256. >
  257. <Box css={{ stack: "y", gap: "medium" }}>
  258. <Box css={{ stack: "x", gap: "small" }}>
  259. <Box css={{ width: "fill" }}>
  260. <TextField
  261. label=""
  262. placeholder="Describe the invoices view you want..."
  263. value={prompt}
  264. onChange={(e) => setPrompt(e.target.value)}
  265. />
  266. </Box>
  267. <Box css={{ alignSelfY: "center" }}>
  268. <Button
  269. type="primary"
  270. onPress={handleGenerate}
  271. disabled={generating || !prompt.trim()}
  272. >
  273. {generating ? "Generating..." : "Generate"}
  274. </Button>
  275. </Box>
  276. </Box>
  277. {spec && (
  278. <StripeRenderer
  279. spec={spec}
  280. data={data}
  281. setData={handleSetState}
  282. loading={generating}
  283. />
  284. )}
  285. </Box>
  286. </ContextView>
  287. );
  288. };
  289. export default Invoices;