@graph8/sdk 0.373.0 → 0.374.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/README.md +3 -3
  2. package/blocks/_foundation/README.md +47 -0
  3. package/blocks/_foundation/block.json +13 -0
  4. package/blocks/_foundation/components/auth-gate.tsx +363 -0
  5. package/blocks/_foundation/server/auth.ts +815 -0
  6. package/blocks/campaigns/README.md +1 -1
  7. package/blocks/campaigns/block.json +34 -1
  8. package/blocks/campaigns/components/campaign-links.tsx +180 -0
  9. package/blocks/company-intelligence/components/research-run.tsx +6 -2
  10. package/blocks/crm/README.md +19 -0
  11. package/blocks/crm/block.json +205 -6
  12. package/blocks/crm/components/bulk-update.tsx +119 -0
  13. package/blocks/crm/components/contact-documents-tab.tsx +144 -0
  14. package/blocks/crm/components/contact-insights-tab.tsx +118 -0
  15. package/blocks/crm/components/contact-lookup-link.tsx +47 -6
  16. package/blocks/crm/components/contact-row-actions.tsx +43 -0
  17. package/blocks/crm/components/contact-tag-chips.tsx +113 -0
  18. package/blocks/crm/components/contacts-screen.tsx +46 -5
  19. package/blocks/crm/components/owner-assign.tsx +132 -0
  20. package/blocks/crm/components/record-open-links.tsx +79 -0
  21. package/blocks/crm/components/tag-assign.tsx +112 -0
  22. package/blocks/crm/components/tag-manager.tsx +178 -0
  23. package/blocks/crm/components/unlock-info-button.tsx +82 -0
  24. package/blocks/crm/components/verify-email-button.tsx +93 -0
  25. package/blocks/crm/hooks/use-contact-tags.ts +29 -0
  26. package/blocks/crm/lib/record-actions.ts +102 -0
  27. package/blocks/deals/README.md +1 -0
  28. package/blocks/deals/block.json +17 -0
  29. package/blocks/deals/components/create-deal-button.tsx +109 -0
  30. package/blocks/deals/components/deal-detail.tsx +5 -3
  31. package/blocks/dialer/components/call-log-screen.tsx +6 -1
  32. package/blocks/documents/README.md +86 -0
  33. package/blocks/documents/block.json +378 -0
  34. package/blocks/documents/components/account-bar.tsx +48 -0
  35. package/blocks/documents/components/correspondence-form.tsx +95 -0
  36. package/blocks/documents/components/correspondence-import.tsx +106 -0
  37. package/blocks/documents/components/correspondence-panel.tsx +191 -0
  38. package/blocks/documents/components/documents-screen.tsx +76 -0
  39. package/blocks/documents/components/library-document-detail.tsx +175 -0
  40. package/blocks/documents/components/library-document-form.tsx +105 -0
  41. package/blocks/documents/components/library-folder-form.tsx +74 -0
  42. package/blocks/documents/components/library-panel.tsx +235 -0
  43. package/blocks/documents/components/library-upload.tsx +160 -0
  44. package/blocks/documents/components/question-row.tsx +130 -0
  45. package/blocks/documents/components/questionnaire-detail.tsx +197 -0
  46. package/blocks/documents/components/questionnaires-panel.tsx +173 -0
  47. package/blocks/documents/components/record-documents-tab.tsx +109 -0
  48. package/blocks/documents/lib/documents-types.ts +252 -0
  49. package/blocks/documents/lib/download.ts +64 -0
  50. package/blocks/documents/recipes/follow-a-questionnaire.ts +26 -0
  51. package/blocks/documents/recipes/log-account-correspondence.ts +27 -0
  52. package/blocks/documents/recipes/organize-the-library.ts +25 -0
  53. package/blocks/email-signatures/README.md +123 -0
  54. package/blocks/email-signatures/block.json +341 -0
  55. package/blocks/email-signatures/components/email-signatures-screen.tsx +166 -0
  56. package/blocks/email-signatures/components/signature-assign-template.tsx +119 -0
  57. package/blocks/email-signatures/components/signature-block-editor.tsx +87 -0
  58. package/blocks/email-signatures/components/signature-campaign-form.tsx +165 -0
  59. package/blocks/email-signatures/components/signature-campaigns.tsx +75 -0
  60. package/blocks/email-signatures/components/signature-identity-form.tsx +131 -0
  61. package/blocks/email-signatures/components/signature-mailboxes.tsx +195 -0
  62. package/blocks/email-signatures/components/signature-policy.tsx +130 -0
  63. package/blocks/email-signatures/components/signature-preview.tsx +119 -0
  64. package/blocks/email-signatures/components/signature-report.tsx +77 -0
  65. package/blocks/email-signatures/components/signature-template-details.tsx +112 -0
  66. package/blocks/email-signatures/components/signature-template-form.tsx +109 -0
  67. package/blocks/email-signatures/hooks/use-signatures-data.ts +93 -0
  68. package/blocks/email-signatures/lib/signatures-types.ts +392 -0
  69. package/blocks/email-signatures/recipes/assign-a-signature-to-a-new-hire.ts +32 -0
  70. package/blocks/email-signatures/recipes/create-a-template-and-make-it-default.ts +35 -0
  71. package/blocks/email-signatures/recipes/signature-engagement-report.ts +16 -0
  72. package/blocks/enrichment/README.md +22 -4
  73. package/blocks/enrichment/block.json +145 -5
  74. package/blocks/enrichment/components/auto-enrichment-configs.tsx +134 -0
  75. package/blocks/enrichment/components/auto-enrichment-settings.tsx +82 -0
  76. package/blocks/enrichment/components/bulk-enrich-panel.tsx +7 -7
  77. package/blocks/enrichment/components/enrichment-activity.tsx +93 -0
  78. package/blocks/enrichment/components/enrichment-job-detail.tsx +18 -0
  79. package/blocks/enrichment/components/enrichment-screen.tsx +34 -3
  80. package/blocks/enrichment/components/list-pipelines.tsx +97 -0
  81. package/blocks/enrichment/components/pipeline-detail.tsx +214 -0
  82. package/blocks/enrichment/components/pipeline-editor.tsx +144 -0
  83. package/blocks/enrichment/components/pipeline-templates.tsx +90 -0
  84. package/blocks/enrichment/lib/enrichment-types.ts +12 -0
  85. package/blocks/enrichment/lib/pipeline-types.ts +234 -0
  86. package/blocks/forms/README.md +2 -0
  87. package/blocks/forms/components/forms-screen.tsx +6 -3
  88. package/blocks/inbox/README.md +40 -4
  89. package/blocks/inbox/block.json +130 -8
  90. package/blocks/inbox/components/blocked-senders.tsx +103 -0
  91. package/blocks/inbox/components/compose-message.tsx +193 -0
  92. package/blocks/inbox/components/inbox-screen.tsx +54 -6
  93. package/blocks/inbox/components/inbox-search.tsx +106 -0
  94. package/blocks/inbox/components/tag-manager.tsx +136 -0
  95. package/blocks/inbox/components/thread-actions.tsx +133 -0
  96. package/blocks/inbox/components/thread-view.tsx +2 -0
  97. package/blocks/inbox/hooks/use-inbox-mailboxes.ts +31 -0
  98. package/blocks/inbox/lib/inbox-manage.ts +227 -0
  99. package/blocks/newsletter/components/newsletter-screen.tsx +3 -1
  100. package/blocks/pages/block.json +10 -1
  101. package/blocks/pages/hooks/use-landing-page-paths.ts +60 -0
  102. package/blocks/programmatic-pages/README.md +82 -0
  103. package/blocks/programmatic-pages/block.json +292 -0
  104. package/blocks/programmatic-pages/components/programmatic-campaign-form.tsx +136 -0
  105. package/blocks/programmatic-pages/components/programmatic-generate.tsx +112 -0
  106. package/blocks/programmatic-pages/components/programmatic-generated-pages.tsx +210 -0
  107. package/blocks/programmatic-pages/components/programmatic-overview.tsx +84 -0
  108. package/blocks/programmatic-pages/components/programmatic-pages-screen.tsx +221 -0
  109. package/blocks/programmatic-pages/components/programmatic-preview.tsx +66 -0
  110. package/blocks/programmatic-pages/components/programmatic-publish.tsx +102 -0
  111. package/blocks/programmatic-pages/components/programmatic-settings.tsx +96 -0
  112. package/blocks/programmatic-pages/components/programmatic-source-fields.tsx +75 -0
  113. package/blocks/programmatic-pages/components/programmatic-source.tsx +106 -0
  114. package/blocks/programmatic-pages/components/programmatic-variables.tsx +84 -0
  115. package/blocks/programmatic-pages/hooks/use-programmatic-workspace.ts +19 -0
  116. package/blocks/programmatic-pages/lib/programmatic-types.ts +330 -0
  117. package/blocks/programmatic-pages/recipes/create-a-programmatic-campaign.ts +23 -0
  118. package/blocks/programmatic-pages/recipes/render-and-follow.ts +21 -0
  119. package/blocks/programmatic-pages/recipes/review-then-publish.ts +17 -0
  120. package/blocks/quotes/README.md +25 -1
  121. package/blocks/quotes/block.json +123 -6
  122. package/blocks/quotes/components/manual-products.tsx +144 -0
  123. package/blocks/quotes/components/quote-batch-actions.tsx +86 -0
  124. package/blocks/quotes/components/quote-documents.tsx +94 -0
  125. package/blocks/quotes/components/quote-evidence.tsx +95 -0
  126. package/blocks/quotes/components/quote-list.tsx +23 -5
  127. package/blocks/quotes/components/quote-settings.tsx +184 -0
  128. package/blocks/quotes/components/quote-templates.tsx +189 -0
  129. package/blocks/quotes/components/quote-viewer.tsx +16 -3
  130. package/blocks/quotes/components/quotes-screen.tsx +15 -5
  131. package/blocks/quotes/lib/quote-admin.ts +264 -0
  132. package/blocks/sequencer/README.md +55 -0
  133. package/blocks/sequencer/block.json +172 -5
  134. package/blocks/sequencer/components/enrollment-withdraw.tsx +71 -0
  135. package/blocks/sequencer/components/sequence-builder.tsx +13 -3
  136. package/blocks/sequencer/components/sequence-campaign-link.tsx +78 -0
  137. package/blocks/sequencer/components/sequence-detail.tsx +6 -0
  138. package/blocks/sequencer/components/sequence-edit-form.tsx +111 -0
  139. package/blocks/sequencer/components/sequence-enrollments.tsx +2 -1
  140. package/blocks/sequencer/components/sequence-manage-actions.tsx +119 -0
  141. package/blocks/sequencer/components/sequence-senders-panel.tsx +84 -0
  142. package/blocks/sequencer/components/sequence-templates.tsx +160 -0
  143. package/blocks/sequencer/components/sequences-screen.tsx +36 -6
  144. package/blocks/sequencer/components/step-ai-assist.tsx +196 -0
  145. package/blocks/sequencer/components/step-editor.tsx +10 -1
  146. package/blocks/sequencer/components/step-templates.tsx +224 -0
  147. package/blocks/sequencer/lib/sequencer-manage.ts +235 -0
  148. package/blocks/sequencer/lib/sequencer-utils.ts +4 -0
  149. package/blocks/signals/README.md +39 -0
  150. package/blocks/signals/block.json +287 -0
  151. package/blocks/signals/components/auto-seed-panel.tsx +111 -0
  152. package/blocks/signals/components/listener-form.tsx +184 -0
  153. package/blocks/signals/components/matched-posts.tsx +195 -0
  154. package/blocks/signals/components/post-engagements.tsx +114 -0
  155. package/blocks/signals/components/record-social-tab.tsx +49 -0
  156. package/blocks/signals/components/social-listeners-screen.tsx +250 -0
  157. package/blocks/signals/components/social-settings.tsx +128 -0
  158. package/blocks/signals/components/source-lists.tsx +83 -0
  159. package/blocks/signals/components/top-engagers.tsx +68 -0
  160. package/blocks/signals/components/x-integration.tsx +129 -0
  161. package/blocks/signals/lib/signals-types.ts +228 -0
  162. package/blocks/signals/recipes/preview-and-create-a-listener.ts +16 -0
  163. package/blocks/signals/recipes/spend-against-caps.ts +14 -0
  164. package/blocks/signals/recipes/triage-matched-posts.ts +18 -0
  165. package/blocks/visitors/components/traffic-chart.tsx +3 -2
  166. package/blocks/web-analytics/README.md +102 -0
  167. package/blocks/web-analytics/block.json +292 -0
  168. package/blocks/web-analytics/components/acquisition-tab.tsx +154 -0
  169. package/blocks/web-analytics/components/analytics-filters.tsx +161 -0
  170. package/blocks/web-analytics/components/audience-tab.tsx +135 -0
  171. package/blocks/web-analytics/components/conversions-tab.tsx +128 -0
  172. package/blocks/web-analytics/components/engagement-tab.tsx +123 -0
  173. package/blocks/web-analytics/components/overview-tab.tsx +102 -0
  174. package/blocks/web-analytics/components/pages-tab.tsx +148 -0
  175. package/blocks/web-analytics/components/ranked-table.tsx +103 -0
  176. package/blocks/web-analytics/components/realtime-tab.tsx +103 -0
  177. package/blocks/web-analytics/components/report-card.tsx +71 -0
  178. package/blocks/web-analytics/components/web-analytics-screen.tsx +90 -0
  179. package/blocks/web-analytics/hooks/use-report.ts +48 -0
  180. package/blocks/web-analytics/lib/analytics-scope.ts +155 -0
  181. package/blocks/web-analytics/lib/page-path.ts +14 -0
  182. package/blocks/web-analytics/lib/web-analytics-types.ts +187 -0
  183. package/blocks/web-analytics/recipes/best-landing-pages.ts +26 -0
  184. package/blocks/web-analytics/recipes/form-conversion-check.ts +19 -0
  185. package/blocks/web-analytics/recipes/weekly-traffic-report.ts +37 -0
  186. package/blocks/workbench/README.md +54 -2
  187. package/blocks/workbench/block.json +234 -5
  188. package/blocks/workbench/components/workbench-auto-enrich.tsx +88 -0
  189. package/blocks/workbench/components/workbench-automation.tsx +36 -0
  190. package/blocks/workbench/components/workbench-connections.tsx +136 -0
  191. package/blocks/workbench/components/workbench-crm-import.tsx +91 -0
  192. package/blocks/workbench/components/workbench-datafile-import.tsx +154 -0
  193. package/blocks/workbench/components/workbench-grid.tsx +70 -7
  194. package/blocks/workbench/components/workbench-pipelines.tsx +173 -0
  195. package/blocks/workbench/components/workbench-row-actions.tsx +8 -1
  196. package/blocks/workbench/components/workbench-schedule.tsx +191 -0
  197. package/blocks/workbench/components/workbench-screen.tsx +50 -17
  198. package/blocks/workbench/components/workbench-send-to-sequence.tsx +116 -0
  199. package/blocks/workbench/components/workbench-table-settings.tsx +53 -0
  200. package/blocks/workbench/components/workbench-triggers.tsx +178 -0
  201. package/blocks/workbench/hooks/use-connected-crms.ts +29 -0
  202. package/blocks/workbench/lib/api-status.ts +8 -0
  203. package/blocks/workbench/lib/automation-types.ts +257 -0
  204. package/dist/cli.js +257 -51
  205. package/dist/index.browser.js +1 -1
  206. package/dist/index.browser.js.map +1 -1
  207. package/dist/index.browser.mjs +1 -1
  208. package/dist/index.browser.mjs.map +1 -1
  209. package/dist/index.js +1 -1
  210. package/dist/index.js.map +1 -1
  211. package/dist/index.mjs +1 -1
  212. package/dist/index.mjs.map +1 -1
  213. package/dist/react.browser.js +1 -1
  214. package/dist/react.browser.js.map +1 -1
  215. package/dist/react.browser.mjs +1 -1
  216. package/dist/react.browser.mjs.map +1 -1
  217. package/dist/react.js +1 -1
  218. package/dist/react.js.map +1 -1
  219. package/dist/react.mjs +1 -1
  220. package/dist/react.mjs.map +1 -1
  221. package/dist/types/blocks/auth.d.ts +45 -0
  222. package/dist/types/blocks/create.d.ts +5 -2
  223. package/dist/types/version.d.ts +2 -2
  224. package/docs/api/forms.md +1 -1
  225. package/package.json +1 -1
  226. package/stacks/6sense/pages/contacts.tsx.tmpl +2 -0
  227. package/stacks/6sense.json +20 -0
  228. package/stacks/apollo/pages/calls.tsx.tmpl +9 -9
  229. package/stacks/apollo/pages/campaigns.tsx.tmpl +27 -0
  230. package/stacks/apollo/pages/contacts.tsx.tmpl +6 -0
  231. package/stacks/apollo/pages/inbox-contact.tsx.tmpl +3 -3
  232. package/stacks/apollo/pages/lists.tsx.tmpl +14 -2
  233. package/stacks/apollo/pages/sequences.tsx.tmpl +8 -2
  234. package/stacks/apollo.json +33 -2
  235. package/stacks/clay/pages/contacts.tsx.tmpl +2 -0
  236. package/stacks/clay/pages/lists.tsx.tmpl +14 -2
  237. package/stacks/gong/pages/calls.tsx.tmpl +9 -9
  238. package/stacks/gong/pages/contacts.tsx.tmpl +2 -0
  239. package/stacks/hootsuite/pages/contacts.tsx.tmpl +2 -0
  240. package/stacks/hootsuite.json +10 -0
  241. package/stacks/hubspot/pages/campaigns.tsx.tmpl +30 -0
  242. package/stacks/hubspot/pages/companies.tsx.tmpl +2 -0
  243. package/stacks/hubspot/pages/contacts.tsx.tmpl +6 -0
  244. package/stacks/hubspot/pages/deals.tsx.tmpl +4 -0
  245. package/stacks/hubspot/pages/forms.tsx.tmpl +36 -0
  246. package/stacks/hubspot/pages/inbox.tsx.tmpl +2 -2
  247. package/stacks/hubspot/pages/sequences.tsx.tmpl +8 -2
  248. package/stacks/hubspot/pages/web-analytics.tsx.tmpl +14 -0
  249. package/stacks/hubspot.json +45 -2
  250. package/stacks/intercom/pages/inbox-contact.tsx.tmpl +3 -3
  251. package/stacks/intercom.json +10 -0
  252. package/stacks/mailchimp/pages/contacts.tsx.tmpl +2 -0
  253. package/stacks/mailchimp/pages/forms.tsx.tmpl +36 -0
  254. package/stacks/mailchimp.json +2 -1
  255. package/stacks/outreach/pages/calls.tsx.tmpl +9 -9
  256. package/stacks/outreach/pages/campaigns.tsx.tmpl +27 -0
  257. package/stacks/outreach/pages/contacts.tsx.tmpl +2 -0
  258. package/stacks/outreach/pages/inbox-contact.tsx.tmpl +3 -3
  259. package/stacks/outreach/pages/sequences.tsx.tmpl +8 -2
  260. package/stacks/outreach.json +13 -2
  261. package/stacks/pipedrive/pages/contacts.tsx.tmpl +2 -0
  262. package/stacks/pipedrive/pages/inbox.tsx.tmpl +2 -2
  263. package/stacks/pipedrive.json +10 -0
  264. package/stacks/salesforce/pages/companies.tsx.tmpl +2 -0
  265. package/stacks/salesforce/pages/contacts.tsx.tmpl +6 -0
  266. package/stacks/salesforce/pages/deals.tsx.tmpl +4 -0
  267. package/stacks/salesforce.json +10 -0
  268. package/stacks/zoominfo/pages/contacts.tsx.tmpl +4 -0
  269. package/stacks/zoominfo/pages/lists.tsx.tmpl +14 -2
  270. package/stacks/zoominfo.json +20 -0
  271. package/styles/styles.css +1 -1
  272. package/templates/auth/auth-route.ts.tmpl +16 -0
  273. package/templates/auth/g8.auth.anonymous.ts.tmpl +15 -0
  274. package/templates/auth/g8.auth.basic.ts.tmpl +17 -0
  275. package/templates/auth/g8.auth.jwt.ts.tmpl +23 -0
  276. package/templates/auth/g8.auth.password.ts.tmpl +18 -0
  277. package/templates/auth/g8.auth.session.ts.tmpl +23 -0
  278. package/templates/auth/middleware.ts.tmpl +14 -0
  279. package/templates/next-app/README.md.tmpl +5 -0
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ /**
4
+ * WorkbenchCrmImport: pull contacts or companies from a connected CRM into this workbench. Lists only CRMs that are connected,
5
+ * asks how many records at most (the API allows 1 to 10,000), and confirms before reading the CRM. Connecting a CRM happens in
6
+ * the platform app (OAuth), so with none connected the panel links there.
7
+ *
8
+ * SDK operations, in order:
9
+ * 1. listCrmSyncs GET /crm-syncs (read, via useConnectedCrms; the CRMs and whether each has a connection id)
10
+ * 2. importWorkbenchFromCrm POST /workbench/tables/{table_id}/import/crm { provider_type, connection_id, entity_type, limit }
11
+ * (external tier: it READS the organisation's own CRM. Nobody is contacted and no credit is charged by the import itself;
12
+ * ConfirmSpendDialog says so. If the workbench auto-enriches, every imported record then spends.)
13
+ */
14
+
15
+ import { useState } from "react";
16
+ import { DatabaseZap } from "lucide-react";
17
+ import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
18
+ import { ApiError } from "@/g8/components/api-error";
19
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
20
+ import { EmptyState } from "@/g8/components/empty-state";
21
+ import { SectionCard } from "@/g8/components/screen/section-card";
22
+ import { useBrand, brandUrl } from "@/g8/hooks/use-brand";
23
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
24
+ import { g8Post } from "@/g8/lib/g8-client";
25
+ import { crmLabel, useConnectedCrms } from "../hooks/use-connected-crms";
26
+
27
+ export interface WorkbenchCrmImportProps {
28
+ tableId: string;
29
+ /** Are the rows people or companies? Sets the CRM object that is read. Default people. */
30
+ entityType?: "contact" | "company";
31
+ autoEnrich?: boolean | null;
32
+ onImported?: () => void;
33
+ className?: string;
34
+ }
35
+
36
+ interface Result { imported?: number; duplicates?: number; columns_created?: number }
37
+
38
+ const MIN_LIMIT = 1;
39
+ const MAX_LIMIT = 10000;
40
+ const DEFAULT_LIMIT = 100;
41
+ const INTEGRATIONS_PATH = "/studio/settings?tab=integrations";
42
+ const label = crmLabel;
43
+ const clamp = (n: number) => (Number.isFinite(n) ? Math.min(MAX_LIMIT, Math.max(MIN_LIMIT, Math.round(n))) : DEFAULT_LIMIT);
44
+
45
+ export function WorkbenchCrmImport({ tableId, entityType = "contact", autoEnrich, onImported, className }: WorkbenchCrmImportProps) {
46
+ const brand = useBrand();
47
+ const syncs = useConnectedCrms();
48
+ const [provider, setProvider] = useState("");
49
+ const [limit, setLimit] = useState(DEFAULT_LIMIT);
50
+ const [confirming, setConfirming] = useState(false);
51
+ const [result, setResult] = useState<Result | null>(null);
52
+
53
+ const connected = syncs.connected ?? [];
54
+ const chosen = connected.find((s) => s.provider === provider);
55
+ const noun = entityType === "company" ? "companies" : "contacts";
56
+ const run = useG8Mutation(async (c: { provider: string; connection_id: string }) =>
57
+ (await g8Post<Result>(`/workbench/tables/${tableId}/import/crm`, { provider_type: c.provider, connection_id: c.connection_id, entity_type: entityType, limit: clamp(limit) })).data, {
58
+ onSuccess: (r) => { setConfirming(false); setResult(r); onImported?.(); },
59
+ onError: () => setConfirming(false),
60
+ });
61
+ const n = (v?: number) => (v ?? 0).toLocaleString();
62
+
63
+ return (
64
+ <SectionCard className={className} title="Import from a CRM" icon={<DatabaseZap aria-hidden />} description={`Pull ${noun} from a connected CRM into this workbench.`}>
65
+ {syncs.error ? <ApiError error={syncs.error} title="Could not load your CRMs" onRetry={() => void syncs.refetch()} /> : null}
66
+ {syncs.loading && !syncs.connected ? <Skeleton className="h-9 w-full" aria-label="Loading CRMs" /> : null}
67
+ {syncs.connected && connected.length === 0 ? (
68
+ <EmptyState compact icon={<DatabaseZap strokeWidth={1.5} />} title="No CRM connected" description="Connect a CRM in the platform app, then come back to import its records."
69
+ action={<Button asChild size="sm" variant="outline"><a href={brandUrl(brand, INTEGRATIONS_PATH)} target="_blank" rel="noreferrer">Connect a CRM</a></Button>} />
70
+ ) : null}
71
+ {connected.length > 0 ? (
72
+ <div className="flex flex-col gap-3">
73
+ <div className="grid gap-3 sm:grid-cols-2">
74
+ <div className="flex flex-col gap-1"><Label htmlFor={`wb-crm-${tableId}`}>CRM</Label>
75
+ <select id={`wb-crm-${tableId}`} className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm" value={provider} onChange={(e) => { setProvider(e.target.value); setResult(null); }}>
76
+ <option value="">Choose a CRM</option>{connected.map((s) => <option key={s.provider} value={s.provider}>{label(s.provider)}</option>)}</select></div>
77
+ <div className="flex flex-col gap-1"><Label htmlFor={`wb-crm-n-${tableId}`}>Most records to pull</Label>
78
+ <Input id={`wb-crm-n-${tableId}`} type="number" min={MIN_LIMIT} max={MAX_LIMIT} value={limit} onChange={(e) => setLimit(clamp(Number(e.target.value)))} /></div>
79
+ </div>
80
+ {autoEnrich ? <p role="status" className="text-sm text-text-color-muted">Auto-enrich is on for this workbench, so every imported record will be enriched and charged.</p> : null}
81
+ {run.error ? <ApiError error={run.error} title="Could not import from the CRM" /> : null}
82
+ {result ? <p role="status" className="text-sm text-text-color-muted">Imported {n(result.imported)} records, {n(result.duplicates)} duplicates skipped, {n(result.columns_created)} new columns.</p> : null}
83
+ <div><Button size="sm" disabled={!chosen} loading={run.loading} onClick={() => setConfirming(true)}>{chosen ? `Import from ${label(chosen.provider)}` : "Import from a CRM"}</Button></div>
84
+ </div>
85
+ ) : null}
86
+ <ConfirmSpendDialog open={confirming && chosen != null} tier="external" title={`Pull records from ${chosen ? label(chosen.provider) : "the CRM"}`}
87
+ description={`Reads up to ${clamp(limit).toLocaleString()} ${noun} from your CRM into this workbench. Nobody is contacted and the import itself charges no credits.${autoEnrich ? " Auto-enrich is on, so those records are then enriched and charged." : ""}`}
88
+ confirmLabel="Pull records" confirming={run.loading} onConfirm={() => chosen && void run.mutate(chosen)} onCancel={() => setConfirming(false)} />
89
+ </SectionCard>
90
+ );
91
+ }
@@ -0,0 +1,154 @@
1
+ "use client";
2
+
3
+ /**
4
+ * WorkbenchDatafileImport: import the rows of a file that is ALREADY uploaded, optionally with an AI-suggested mapping from the
5
+ * file's headers to this workbench's columns. The API takes a datafile id (the upload itself is not part of the developer API),
6
+ * so the person pastes the id and, for a mapping, the file's header names.
7
+ *
8
+ * SDK operations, in order:
9
+ * 1. suggestWorkbenchMappings POST /workbench/tables/{table_id}/suggest-mappings { source_columns, target_fields }
10
+ * (billable tier: ONE AI call, no published price, so ConfirmSpendDialog states that and shows no number; nothing is mapped or imported by it)
11
+ * 2. getWorkbenchMappingSuggestion GET /workbench/tables/{table_id}/suggest-mappings/{job_id} (read; polled until completed or failed, an unknown status keeps polling)
12
+ * 3. importWorkbenchDatafile POST /workbench/tables/{table_id}/import/datafile { datafile_id, skip_duplicates, column_mapping? } (write, free)
13
+ *
14
+ * Only mappings the person kept are sent (an unmapped header is left out of `column_mapping`). If the workbench auto-enriches, the
15
+ * imported rows will spend: the import then goes through a billable ConfirmSpendDialog (no number: no price is published for rows
16
+ * that have not arrived), and without auto-enrich it does not need one. Changing the headers drops a mapping job in flight.
17
+ */
18
+
19
+ import { useEffect, useState } from "react";
20
+ import { FileUp, Sparkles } from "lucide-react";
21
+ import { Button, Input, Label, Switch } from "@graph8/sdk/ui";
22
+ import { ApiError } from "@/g8/components/api-error";
23
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
24
+ import { SectionCard } from "@/g8/components/screen/section-card";
25
+ import { StatusPill } from "@/g8/components/screen/status-pill";
26
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
27
+ import { g8Get, g8Post } from "@/g8/lib/g8-client";
28
+ import { mappingDone } from "../lib/automation-types";
29
+ import type { MappingSuggestion } from "../lib/automation-types";
30
+ import type { WorkbenchColumn } from "../lib/workbench-types";
31
+
32
+ export interface WorkbenchDatafileImportProps {
33
+ tableId: string;
34
+ columns?: WorkbenchColumn[];
35
+ /** The table's auto-enrich flag: imported rows then spend, and the panel warns. */
36
+ autoEnrich?: boolean | null;
37
+ /** Called after rows were imported so the grid can reload. */
38
+ onImported?: () => void;
39
+ /** Milliseconds between checks of a mapping job. Default 1500. */
40
+ pollMs?: number;
41
+ className?: string;
42
+ }
43
+
44
+ interface MapRow { source: string; target: string; confidence?: number }
45
+ interface ImportResult { inserted?: number; duplicates?: number; columns_created?: number }
46
+
47
+ const MAX_HEADERS = 100;
48
+ const MAX_POLLS = 40;
49
+ const plural = (n: number, one: string, many: string) => `${n.toLocaleString()} ${n === 1 ? one : many}`;
50
+
51
+ /** Unique, trimmed, non-empty header names, in the order typed. */
52
+ export function parseHeaders(text: string): string[] {
53
+ return [...new Set(text.split(",").map((h) => h.trim()).filter((h) => h.length > 0 && h.length <= 255))].slice(0, MAX_HEADERS);
54
+ }
55
+
56
+ export function WorkbenchDatafileImport({ tableId, columns = [], autoEnrich, onImported, pollMs = 1500, className }: WorkbenchDatafileImportProps) {
57
+ const [datafileId, setDatafileId] = useState("");
58
+ const [headers, setHeaders] = useState("");
59
+ const [skipDup, setSkipDup] = useState(true);
60
+ const [mapping, setMapping] = useState<MapRow[] | null>(null);
61
+ const [asking, setAsking] = useState(false);
62
+ const [confirmImport, setConfirmImport] = useState(false);
63
+ const [job, setJob] = useState<string | null>(null);
64
+ const [mapError, setMapError] = useState<string | null>(null);
65
+ const [result, setResult] = useState<ImportResult | null>(null);
66
+
67
+ const targets = columns.map((c) => c.name);
68
+ const sources = parseHeaders(headers);
69
+ const base = `/workbench/tables/${tableId}/suggest-mappings`;
70
+
71
+ const apply = (s: MappingSuggestion) => {
72
+ setJob(null);
73
+ if (s.status === "failed") { setMapError(s.message || "The mapping job failed. Map the columns by hand or try again."); return; }
74
+ const byName = new Map((s.mappings ?? []).map((m) => [m.source ?? "", m]));
75
+ setMapping(sources.map((source) => {
76
+ const m = byName.get(source);
77
+ return { source, target: m?.target && targets.includes(m.target) ? m.target : "", confidence: m?.confidence };
78
+ }));
79
+ };
80
+ const suggest = useG8Mutation(async () => (await g8Post<MappingSuggestion>(base, { source_columns: sources, target_fields: targets })).data, {
81
+ onSuccess: (s) => { setAsking(false); if (mappingDone(s.status)) apply(s); else if (s.job_id) setJob(s.job_id); else setMapError("No mapping job was started. Try again."); },
82
+ onError: () => setAsking(false),
83
+ });
84
+ const run = useG8Mutation(async () => {
85
+ const column_mapping = Object.fromEntries((mapping ?? []).filter((m) => m.target).map((m) => [m.source, m.target]));
86
+ return (await g8Post<ImportResult>(`/workbench/tables/${tableId}/import/datafile`, { datafile_id: datafileId.trim(), skip_duplicates: skipDup, ...(Object.keys(column_mapping).length ? { column_mapping } : {}) })).data;
87
+ }, { onSuccess: (r) => { setConfirmImport(false); setResult(r); onImported?.(); }, onError: () => setConfirmImport(false) });
88
+
89
+ // Follow the mapping job: check at once, then every pollMs, until it is completed or failed (anything else keeps going).
90
+ useEffect(() => {
91
+ if (!job) return;
92
+ let stop = false;
93
+ let tries = 0;
94
+ let timer: ReturnType<typeof setTimeout> | undefined;
95
+ const tick = async () => {
96
+ try {
97
+ const s = (await g8Get<MappingSuggestion>(`${base}/${encodeURIComponent(job)}`)).data;
98
+ if (stop) return;
99
+ if (mappingDone(s.status)) { apply(s); return; }
100
+ } catch { if (!stop) { setJob(null); setMapError("Could not check the mapping job. Try again."); } return; }
101
+ if (++tries >= MAX_POLLS) { if (!stop) { setJob(null); setMapError("The mapping is taking too long. Try again in a moment."); } return; }
102
+ timer = setTimeout(() => void tick(), pollMs);
103
+ };
104
+ void tick();
105
+ return () => { stop = true; if (timer) clearTimeout(timer); };
106
+ }, [job]); // eslint-disable-line react-hooks/exhaustive-deps
107
+
108
+ const waiting = suggest.loading || job != null;
109
+ return (
110
+ <SectionCard className={className} title="Import a file" icon={<FileUp aria-hidden />}
111
+ description="Bring in the rows of a file that is already uploaded. Free, and rows land unlocked.">
112
+ <div className="flex flex-col gap-3">
113
+ <div className="flex flex-col gap-1"><Label htmlFor={`wb-df-${tableId}`}>Datafile id</Label>
114
+ <Input id={`wb-df-${tableId}`} placeholder="The id of the uploaded file" value={datafileId} onChange={(e) => { setDatafileId(e.target.value); setResult(null); }} />
115
+ <p className="text-xs text-text-color-muted">The developer API imports a file that is already uploaded. Upload it in the platform app first, then paste its id here.</p></div>
116
+ <div className="flex flex-col gap-1"><Label htmlFor={`wb-hd-${tableId}`}>File headers</Label>
117
+ <Input id={`wb-hd-${tableId}`} placeholder="Name, Email, Company (optional, for a suggested mapping)" value={headers} onChange={(e) => { setHeaders(e.target.value); setMapping(null); setMapError(null); setJob(null); }} /></div>
118
+ <div className="flex flex-wrap items-center gap-2">
119
+ <Button size="sm" variant="outline" disabled={sources.length === 0 || targets.length === 0 || waiting} loading={waiting} onClick={() => { setMapError(null); setAsking(true); }}>
120
+ <Sparkles className="mr-1 size-4" aria-hidden />Suggest column mapping</Button>
121
+ {sources.length > 0 && targets.length === 0 ? <span className="text-xs text-text-color-muted">Add a column to this workbench first.</span> : null}
122
+ </div>
123
+ {mapError ? <p role="alert" className="text-sm text-destructive">{mapError}</p> : null}
124
+ {suggest.error ? <ApiError error={suggest.error} title="Could not ask for a mapping" /> : null}
125
+ {mapping ? (
126
+ <ul className="flex flex-col gap-2" aria-label="Suggested mapping">
127
+ {mapping.map((m, i) => (
128
+ <li key={m.source} className="flex flex-wrap items-center gap-2">
129
+ <span className="w-40 truncate text-sm">{m.source}</span>
130
+ <select aria-label={`Map ${m.source} to`} className="h-9 min-w-40 flex-1 rounded-md border border-border bg-background px-2 text-sm" value={m.target}
131
+ onChange={(e) => setMapping(mapping.map((x, j) => (j === i ? { ...x, target: e.target.value, confidence: undefined } : x)))}>
132
+ <option value="">Do not import</option>{columns.map((c) => <option key={c.id} value={c.name}>{c.display_name || c.name}</option>)}</select>
133
+ {m.target && m.confidence ? <StatusPill tone={m.confidence >= 0.8 ? "positive" : "warning"}>{Math.round(m.confidence * 100)}% sure</StatusPill> : null}
134
+ </li>
135
+ ))}
136
+ </ul>
137
+ ) : null}
138
+ {autoEnrich ? <p role="status" className="text-sm text-text-color-muted">Auto-enrich is on for this workbench, so every imported row will be enriched and charged.</p> : null}
139
+ {run.error ? <ApiError error={run.error} title="Could not import the file" /> : null}
140
+ {result ? <p role="status" className="text-sm text-text-color-muted">Imported {plural(result.inserted ?? 0, "row", "rows")}, {plural(result.duplicates ?? 0, "duplicate", "duplicates")} skipped, {plural(result.columns_created ?? 0, "new column", "new columns")}.</p> : null}
141
+ <div className="flex flex-wrap items-center justify-between gap-3">
142
+ <div className="flex items-center gap-2"><Switch id={`wb-skip-${tableId}`} aria-label="Skip rows already in the workbench" checked={skipDup} onCheckedChange={setSkipDup} /><Label htmlFor={`wb-skip-${tableId}`}>Skip rows already in the workbench</Label></div>
143
+ <Button size="sm" loading={run.loading} disabled={!datafileId.trim()} onClick={() => { if (autoEnrich) setConfirmImport(true); else void run.mutate(undefined); }}>Import file</Button>
144
+ </div>
145
+ </div>
146
+ <ConfirmSpendDialog open={confirmImport} tier="billable" title="Import into an auto-enriching workbench"
147
+ description="Auto-enrich is on, so every imported row is then enriched and charged, with nobody in the loop. No total is published for rows that have not arrived yet, so none is shown. The import itself is free."
148
+ confirmLabel="Import and enrich" confirming={run.loading} onConfirm={() => void run.mutate(undefined)} onCancel={() => setConfirmImport(false)} />
149
+ <ConfirmSpendDialog open={asking} tier="billable" title="Suggest a column mapping"
150
+ description="This makes one AI call to match your file headers to this workbench's columns. No price is published for it, so none is shown. Nothing is mapped or imported until you press Import."
151
+ confirmLabel="Suggest mapping" confirming={suggest.loading} onConfirm={() => void suggest.mutate(undefined)} onCancel={() => setAsking(false)} />
152
+ </SectionCard>
153
+ );
154
+ }
@@ -14,19 +14,24 @@
14
14
  * (the panels it opens call: addWorkbenchRow, pasteImportWorkbenchRows, addWorkbenchColumn, saveWorkbenchColumnAiConfig,
15
15
  * saveWorkbenchWaterfall, createWorkbenchFormulaColumns, mapWorkbenchColumn, quote*Run + run* (billable, confirmed),
16
16
  * getWorkbenchHistory, getWorkbenchAnalytics, quoteWorkbenchUnlock, unlockWorkbenchRows (billable, confirmed),
17
- * exportWorkbenchTable, bulkDeleteWorkbenchRows (destructive, confirmed))
17
+ * exportWorkbenchTable, bulkDeleteWorkbenchRows (destructive, confirmed), sendWorkbenchRowsToSequence (external, confirmed))
18
+ * 5. queryWorkbenchRows POST /workbench/tables/{table_id}/rows/query { start_row, end_row, filter_model, sort_model } (read, free)
19
+ * Used instead of (3) only while a filter or sort is set: a text "contains" filter on one column and/or a sort by one column.
20
+ * 6. deleteWorkbenchColumn DELETE /workbench/tables/{table_id}/columns/{column_id} (destructive, ConfirmSpendDialog; removes the column's values from every row)
18
21
  *
19
22
  * A locked row (credit_charged false) shows masked cells and cannot be edited: unlock it first (1 credit per row).
20
23
  * Cells show `extracted_data` (what a recipe wrote) before `raw_data` (what was imported).
21
24
  */
22
25
 
23
26
  import { useEffect, useState } from "react";
27
+ import { Trash2 } from "lucide-react";
24
28
  import { Button, Input, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
25
29
  import { ApiError } from "@/g8/components/api-error";
30
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
26
31
  import { EmptyState } from "@/g8/components/empty-state";
27
32
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
28
33
  import { useG8Query } from "@/g8/hooks/use-g8-query";
29
- import { g8Get, g8Patch } from "@/g8/lib/g8-client";
34
+ import { g8Delete, g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
30
35
  import { useWorkbenchTable } from "../hooks/use-workbench-table";
31
36
  import { cellValue, formatCell, isLocked, isMasked, pageItems, pageTotal } from "../lib/workbench-types";
32
37
  import type { RunTarget, WorkbenchColumn, WorkbenchRow } from "../lib/workbench-types";
@@ -43,18 +48,29 @@ export interface WorkbenchGridProps {
43
48
  onOpenRow?: (row: WorkbenchRow) => void;
44
49
  /** Highlights the open row. */
45
50
  activeRowId?: number | null;
51
+ /** Called whenever the grid reloaded its rows after an action (edit, add, run, unlock, delete), so a header count can refresh. */
52
+ onChanged?: () => void;
46
53
  className?: string;
47
54
  }
48
55
 
49
56
  type Panel = "rows" | "columns" | "selection" | null;
50
57
 
58
+ const selectClass = "h-9 rounded-md border border-border bg-background px-2 text-sm";
59
+
60
+ /** The value after it stopped changing for `ms`, so typing a filter does not fire a request per keystroke. */
61
+ function useDebounced<T>(value: T, ms = 300): T {
62
+ const [v, setV] = useState(value);
63
+ useEffect(() => { const t = setTimeout(() => setV(value), ms); return () => clearTimeout(t); }, [value, ms]);
64
+ return v;
65
+ }
66
+
51
67
  function runTarget(column: WorkbenchColumn): RunTarget | null {
52
68
  if (column.enrichment_type === "waterfall") return { kind: "waterfall", columnId: column.id };
53
69
  if (column.enrichment_type === "ai") return { kind: "ai-column", columnId: column.id };
54
70
  return null;
55
71
  }
56
72
 
57
- export function WorkbenchGrid({ tableId, pageSize = 100, onOpenRow, activeRowId, className }: WorkbenchGridProps) {
73
+ export function WorkbenchGrid({ tableId, pageSize = 100, onOpenRow, activeRowId, onChanged, className }: WorkbenchGridProps) {
58
74
  const [end, setEnd] = useState(pageSize);
59
75
  const [panel, setPanel] = useState<Panel>(null);
60
76
  const [selected, setSelected] = useState<number[]>([]);
@@ -62,7 +78,32 @@ export function WorkbenchGrid({ tableId, pageSize = 100, onOpenRow, activeRowId,
62
78
  const [running, setRunning] = useState<WorkbenchColumn | null>(null);
63
79
  const { table, columns, loading: metaLoading, error: metaError, refetch: refetchMeta } = useWorkbenchTable(tableId);
64
80
 
65
- const rows = useG8Query(async (signal) => (await g8Get(`/workbench/tables/${tableId}/rows`, { start_row: 0, end_row: end }, { signal })).data, [tableId, end]);
81
+ // A filter or sort goes through the query endpoint (same row shape); with neither set the plain read is used, exactly as before.
82
+ const [filterCol, setFilterCol] = useState("");
83
+ const [filterText, setFilterText] = useState("");
84
+ const [sortCol, setSortCol] = useState("");
85
+ const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
86
+ const [deletingColumn, setDeletingColumn] = useState<WorkbenchColumn | null>(null);
87
+ const term = useDebounced(filterText.trim());
88
+ const filtering = filterCol !== "" && term !== "";
89
+ const sorting = sortCol !== "";
90
+ const rows = useG8Query(async (signal) => {
91
+ if (!filtering && !sorting) return (await g8Get(`/workbench/tables/${tableId}/rows`, { start_row: 0, end_row: end }, { signal })).data;
92
+ return (await g8Post(`/workbench/tables/${tableId}/rows/query`, {
93
+ start_row: 0, end_row: end,
94
+ ...(filtering ? { filter_model: { [filterCol]: { filterType: "text", type: "contains", filter: term } } } : {}),
95
+ ...(sorting ? { sort_model: [{ colId: sortCol, sort: sortDir }] } : {}),
96
+ }, { signal })).data;
97
+ }, [tableId, end, filtering ? filterCol : "", filtering ? term : "", sortCol, sortDir]);
98
+ const removeColumn = useG8Mutation((c: WorkbenchColumn) => g8Delete(`/workbench/tables/${tableId}/columns/${c.id}`), {
99
+ onSuccess: (_r, c) => {
100
+ setDeletingColumn(null);
101
+ if (filterCol === c.name) { setFilterCol(""); setFilterText(""); }
102
+ if (sortCol === c.name) setSortCol("");
103
+ reload();
104
+ },
105
+ onError: () => setDeletingColumn(null),
106
+ });
66
107
  const items = pageItems<WorkbenchRow>(rows.data);
67
108
  const total = pageTotal(rows.data, items.length);
68
109
  const save = useG8Mutation(
@@ -71,16 +112,17 @@ export function WorkbenchGrid({ tableId, pageSize = 100, onOpenRow, activeRowId,
71
112
  );
72
113
  // Selection and open panels belong to one table and one set of rows: drop them when the table changes,
73
114
  // and prune the selection to rows that still exist after any reload (delete, import, run).
74
- useEffect(() => { setSelected([]); setEditing(null); setRunning(null); setEnd(pageSize); }, [tableId, pageSize]);
115
+ useEffect(() => { setSelected([]); setEditing(null); setRunning(null); setEnd(pageSize); setFilterCol(""); setFilterText(""); setSortCol(""); }, [tableId, pageSize]);
75
116
  useEffect(() => {
76
117
  if (rows.loading || !rows.data) return;
77
118
  const alive = new Set(items.map((r) => r.id));
78
119
  setSelected((cur) => (cur.every((id) => alive.has(id)) ? cur : cur.filter((id) => alive.has(id))));
79
120
  }, [rows.data, rows.loading]); // eslint-disable-line react-hooks/exhaustive-deps
80
- const reload = () => { void rows.refetch(); void refetchMeta(); };
121
+ const reload = () => { void rows.refetch(); void refetchMeta(); onChanged?.(); };
81
122
  const toggle = (id: number) => setSelected(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]);
82
123
  const allOn = items.length > 0 && items.every((r) => selected.includes(r.id));
83
124
  const entityType = table?.entity_type === "company" ? "company" : "contact";
125
+ const lockedSelected = items.filter((r) => selected.includes(r.id) && isLocked(r)).map((r) => r.id);
84
126
 
85
127
  if (metaError) return <ApiError error={metaError} title="Could not load the table" onRetry={() => void refetchMeta()} />;
86
128
 
@@ -100,14 +142,30 @@ export function WorkbenchGrid({ tableId, pageSize = 100, onOpenRow, activeRowId,
100
142
 
101
143
  {panel === "rows" ? <WorkbenchAddRows tableId={tableId} columns={columns} onAdded={reload} /> : null}
102
144
  {panel === "columns" ? <ColumnRecipeEditor tableId={tableId} columns={columns} entityType={entityType} onSaved={reload} /> : null}
103
- {panel === "selection" ? <WorkbenchRowActions tableId={tableId} rowIds={selected} entityType={entityType} onChanged={(what) => { if (what !== "exported") setSelected([]); reload(); }} /> : null}
145
+ {panel === "selection" ? <WorkbenchRowActions tableId={tableId} rowIds={selected} lockedRowIds={lockedSelected} entityType={entityType} onChanged={(what) => { if (what !== "exported") setSelected([]); reload(); }} /> : null}
104
146
  {running ? (
105
147
  <div className="rounded-md border p-3">
106
148
  <RunProgress key={running.id} tableId={tableId} target={runTarget(running)!} rowIds={selected.length ? selected : undefined} label={running.display_name || running.name} onFinished={reload} />
107
149
  <Button size="sm" variant="ghost" onClick={() => setRunning(null)}>Close</Button>
108
150
  </div>
109
151
  ) : null}
152
+ {columns.length > 0 ? (
153
+ <div className="flex flex-wrap items-end gap-2" role="search" aria-label="Filter and sort rows">
154
+ <select aria-label="Filter column" className={selectClass} value={filterCol} onChange={(e) => setFilterCol(e.target.value)}>
155
+ <option value="">Filter by column</option>{columns.map((c) => <option key={c.id} value={c.name}>{c.display_name || c.name}</option>)}
156
+ </select>
157
+ <Input aria-label="Filter rows" className="w-48" placeholder="Contains..." disabled={filterCol === ""} value={filterText} onChange={(e) => setFilterText(e.target.value)} />
158
+ <select aria-label="Sort by" className={selectClass} value={sortCol} onChange={(e) => setSortCol(e.target.value)}>
159
+ <option value="">No sort</option>{columns.map((c) => <option key={c.id} value={c.name}>{c.display_name || c.name}</option>)}
160
+ </select>
161
+ <select aria-label="Sort direction" className={selectClass} value={sortDir} disabled={sortCol === ""} onChange={(e) => setSortDir(e.target.value as "asc" | "desc")}>
162
+ <option value="asc">Ascending</option><option value="desc">Descending</option>
163
+ </select>
164
+ {filtering ? <span className="pb-2 text-xs text-muted-foreground">{total.toLocaleString()} matching {total === 1 ? "row" : "rows"}</span> : null}
165
+ </div>
166
+ ) : null}
110
167
  {save.error ? <ApiError error={save.error} title="Could not save the cell" /> : null}
168
+ {removeColumn.error ? <ApiError error={removeColumn.error} title="Could not delete the column" /> : null}
111
169
  {rows.error ? <ApiError error={rows.error} title="Could not load the rows" onRetry={() => void rows.refetch()} /> : null}
112
170
 
113
171
  {(metaLoading || rows.loading) && items.length === 0 ? <Skeleton className="h-40 w-full" /> : null}
@@ -127,6 +185,7 @@ export function WorkbenchGrid({ tableId, pageSize = 100, onOpenRow, activeRowId,
127
185
  <span>{c.display_name || c.name}</span>
128
186
  {c.enrichment_type ? <span className="rounded bg-muted px-1 text-xs">{c.enrichment_type}</span> : null}
129
187
  {target ? <Button size="sm" variant="ghost" aria-label={`Run ${c.display_name || c.name}`} onClick={() => setRunning(c)}>Run</Button> : null}
188
+ <Button size="sm" variant="ghost" aria-label={`Delete column ${c.display_name || c.name}`} onClick={() => setDeletingColumn(c)}><Trash2 className="size-3.5" aria-hidden /></Button>
130
189
  </div>
131
190
  </TableHead>
132
191
  );
@@ -159,6 +218,10 @@ export function WorkbenchGrid({ tableId, pageSize = 100, onOpenRow, activeRowId,
159
218
  </Table>
160
219
  </div>
161
220
  ) : null}
221
+ <ConfirmSpendDialog open={deletingColumn != null} tier="destructive" title={`Delete the column "${deletingColumn?.display_name || deletingColumn?.name || ""}"`}
222
+ description="The values in this column are removed from every row, and any recipe on it stops. This cannot be undone."
223
+ affected={total} confirmLabel="Delete column" confirming={removeColumn.loading}
224
+ onConfirm={() => deletingColumn && void removeColumn.mutate(deletingColumn)} onCancel={() => setDeletingColumn(null)} />
162
225
  {items.length < total ? <div><Button size="sm" variant="outline" loading={rows.loading} onClick={() => setEnd(end + pageSize)}>Load more ({items.length.toLocaleString()} of {total.toLocaleString()})</Button></div> : null}
163
226
  </div>
164
227
  );
@@ -0,0 +1,173 @@
1
+ "use client";
2
+
3
+ /**
4
+ * WorkbenchPipelines: a pipeline is an ordered run of the table's recipe columns (AI columns, provider waterfalls,
5
+ * formulas) that starts on an event (rows added, a webhook call, an email with a PDF, a scraper run). Lists the pipelines
6
+ * with a switch each, opens one to read its start and steps, creates one in a side sheet (always saved switched OFF), renames
7
+ * or switches one, and deletes with a confirm.
8
+ *
9
+ * SDK operations, in order:
10
+ * 1. listWorkbenchPipelines GET /workbench/tables/{table_id}/pipelines (read)
11
+ * 2. getWorkbenchPipeline GET /workbench/tables/{table_id}/pipelines/{pipeline_id} (read; the detail sheet)
12
+ * 3. createWorkbenchPipeline POST /workbench/tables/{table_id}/pipelines (billable tier: created DISABLED, so nothing is armed)
13
+ * 4. updateWorkbenchPipeline PUT /workbench/tables/{table_id}/pipelines/{pipeline_id} (billable tier: ENABLING one with AI or waterfall steps arms spend; sends only `enabled`)
14
+ * 5. deleteWorkbenchPipeline DELETE /workbench/tables/{table_id}/pipelines/{pipeline_id} (destructive, ConfirmSpendDialog; a 404 means already gone)
15
+ *
16
+ * An enabled pipeline runs its AI and provider steps on every new row and charges for them. graph8 publishes no total up
17
+ * front, so the enable confirm says so and shows no number. Steps with branches or list exports that other tools created are
18
+ * shown read-only and are never rewritten here: an enable or disable sends only `enabled`.
19
+ */
20
+
21
+ import { useState } from "react";
22
+ import { GitBranch } from "lucide-react";
23
+ import { Button, Input, Label, Skeleton, Switch } from "@graph8/sdk/ui";
24
+ import { ApiError } from "@/g8/components/api-error";
25
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
26
+ import { EmptyState } from "@/g8/components/empty-state";
27
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
28
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
29
+ import { SectionCard } from "@/g8/components/screen/section-card";
30
+ import { StatusPill } from "@/g8/components/screen/status-pill";
31
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
32
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
33
+ import { g8Delete, g8Get, g8Post, g8Put } from "@/g8/lib/g8-client";
34
+ import { isNotFound } from "../lib/api-status";
35
+ import { PIPELINE_STARTS, STEP_TYPES, labelOf, pipelineSpends, rowsOf } from "../lib/automation-types";
36
+ import type { WorkbenchPipeline, WorkbenchPipelineStep } from "../lib/automation-types";
37
+ import type { WorkbenchColumn } from "../lib/workbench-types";
38
+
39
+ export interface WorkbenchPipelinesProps {
40
+ tableId: string;
41
+ /** The table's columns: its recipe columns (AI, waterfall, formula) become the steps you can pick. */
42
+ columns?: WorkbenchColumn[];
43
+ className?: string;
44
+ }
45
+
46
+ const selectClass = "h-9 w-full rounded-md border border-border bg-background px-2 text-sm";
47
+ const SPEND_NOTE = "An enabled pipeline runs its AI and provider steps on every new row and charges for them. No total is published up front, so none is shown.";
48
+ const newStepId = () => `step_${Math.random().toString(36).slice(2, 10)}`;
49
+
50
+ /** The step a recipe column becomes, or null when it cannot be one (an AI column with no group yet). */
51
+ function stepFor(c: WorkbenchColumn): WorkbenchPipelineStep | null {
52
+ const name = c.display_name || c.name;
53
+ if (c.enrichment_type === "ai") return c.enrichment_group_id ? { id: newStepId(), type: "ai_group", name, enabled: true, group_id: c.enrichment_group_id } : null;
54
+ if (c.enrichment_type === "waterfall") return { id: newStepId(), type: "waterfall", name, enabled: true, column_id: c.id };
55
+ if (c.column_type === "formula") return { id: newStepId(), type: "formula", name, enabled: true, column_id: c.id };
56
+ return null;
57
+ }
58
+
59
+ function PipelineDetail({ tableId, pipeline, onClose }: { tableId: string; pipeline: WorkbenchPipeline | null; onClose: () => void }) {
60
+ const read = useG8Query(async (signal) => (await g8Get<WorkbenchPipeline>(`/workbench/tables/${tableId}/pipelines/${pipeline?.id}`, undefined, { signal })).data, [tableId, pipeline?.id], { enabled: pipeline != null });
61
+ const p = read.data ?? pipeline;
62
+ return (
63
+ <FormSheet open={pipeline != null} onOpenChange={(o) => !o && onClose()} title={pipeline?.name ?? "Pipeline"} description="Read-only: the start and the steps, in the order they run.">
64
+ {read.error ? <ApiError error={read.error} title="Could not load the pipeline" onRetry={() => void read.refetch()} /> : null}
65
+ {read.loading && !read.data ? <Skeleton className="h-16 w-full" aria-label="Loading pipeline" /> : null}
66
+ {p ? (
67
+ <div className="flex flex-col gap-4">
68
+ <KeyValueList items={[
69
+ { label: "Starts when", value: labelOf(PIPELINE_STARTS, p.start?.type ?? p.start_type) },
70
+ { label: "State", value: p.enabled ? "On" : "Off" },
71
+ { label: "Steps", value: String((p.steps ?? []).length) },
72
+ ]} />
73
+ <ol className="flex flex-col gap-2">
74
+ {(p.steps ?? []).map((s, i) => (
75
+ <li key={s.id} className="flex items-center gap-3 rounded-md border border-border p-3">
76
+ <span className="flex size-6 items-center justify-center rounded-full bg-muted text-xs">{i + 1}</span>
77
+ <div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{s.name}</p><p className="text-xs text-text-color-muted">{labelOf(STEP_TYPES, s.type)}</p></div>
78
+ {s.enabled === false ? <StatusPill tone="neutral">Skipped</StatusPill> : null}
79
+ </li>
80
+ ))}
81
+ </ol>
82
+ </div>
83
+ ) : null}
84
+ </FormSheet>
85
+ );
86
+ }
87
+
88
+ export function WorkbenchPipelines({ tableId, columns = [], className }: WorkbenchPipelinesProps) {
89
+ const path = `/workbench/tables/${tableId}/pipelines`;
90
+ const list = useG8Query(async (signal) => rowsOf<WorkbenchPipeline>((await g8Get(path, undefined, { signal })).data), [tableId]);
91
+ const [creating, setCreating] = useState(false);
92
+ const [name, setName] = useState("");
93
+ const [start, setStart] = useState("ingest");
94
+ const [picked, setPicked] = useState<number[]>([]);
95
+ const [viewing, setViewing] = useState<WorkbenchPipeline | null>(null);
96
+ const [enabling, setEnabling] = useState<WorkbenchPipeline | null>(null);
97
+ const [deleting, setDeleting] = useState<WorkbenchPipeline | null>(null);
98
+ const [busyId, setBusyId] = useState<string | null>(null);
99
+
100
+ const recipeColumns = columns.filter((c) => c.enrichment_type || c.column_type === "formula");
101
+ const create = useG8Mutation(async () => {
102
+ const steps = recipeColumns.filter((c) => picked.includes(c.id)).map(stepFor).filter((s): s is WorkbenchPipelineStep => s != null);
103
+ return (await g8Post(path, { name: name.trim(), start: { type: start }, steps, enabled: false })).data;
104
+ }, { onSuccess: () => { setCreating(false); setName(""); setPicked([]); void list.refetch(); } });
105
+ const toggle = useG8Mutation(async (v: { pipeline: WorkbenchPipeline; enabled: boolean }) => { setBusyId(v.pipeline.id); return (await g8Put(`${path}/${v.pipeline.id}`, { enabled: v.enabled })).data; }, {
106
+ onSuccess: () => { setBusyId(null); setEnabling(null); void list.refetch(); },
107
+ onError: () => { setBusyId(null); setEnabling(null); },
108
+ });
109
+ const remove = useG8Mutation(async (p: WorkbenchPipeline) => { try { await g8Delete(`${path}/${p.id}`); } catch (e) { if (!isNotFound(e)) throw e; } }, {
110
+ onSuccess: () => { setDeleting(null); void list.refetch(); },
111
+ });
112
+ const items = list.data ?? [];
113
+ const open = () => { create.reset(); setCreating(true); };
114
+
115
+ return (
116
+ <SectionCard className={className} title="Pipelines" icon={<GitBranch aria-hidden />}
117
+ description="Run your AI, provider and formula columns in order whenever new data arrives."
118
+ actions={<Button size="sm" onClick={open}>New pipeline</Button>}>
119
+ {list.error ? <ApiError error={list.error} title="Could not load pipelines" onRetry={() => void list.refetch()} /> : null}
120
+ {toggle.error ? <ApiError error={toggle.error} title="Could not change the pipeline" /> : null}
121
+ {remove.error ? <ApiError error={remove.error} title="Could not delete the pipeline" /> : null}
122
+ {list.loading && !list.data ? <Skeleton className="h-16 w-full" aria-label="Loading pipelines" /> : null}
123
+ {!list.loading && !list.error && items.length === 0 ? (
124
+ <EmptyState compact icon={<GitBranch strokeWidth={1.5} />} title="No pipelines yet" description="Chain your recipe columns so they run in order on every new row."
125
+ action={<Button size="sm" onClick={open}>New pipeline</Button>} />
126
+ ) : null}
127
+ <ul className="flex flex-col divide-y divide-border">
128
+ {items.map((p) => (
129
+ <li key={p.id} className="flex flex-wrap items-center gap-3 py-3">
130
+ <div className="min-w-0 flex-1">
131
+ <p className="flex items-center gap-2 text-sm font-medium"><span className="truncate">{p.name}</span>{pipelineSpends(p) ? <StatusPill tone="warning">Uses credits</StatusPill> : null}</p>
132
+ <p className="text-xs text-text-color-muted">Starts on: {labelOf(PIPELINE_STARTS, p.start?.type ?? p.start_type)}, {(p.steps ?? []).length} {(p.steps ?? []).length === 1 ? "step" : "steps"}</p>
133
+ </div>
134
+ <Switch aria-label={`Enable pipeline ${p.name}`} checked={p.enabled === true} disabled={busyId === p.id}
135
+ onCheckedChange={(on) => { if (on && pipelineSpends(p)) setEnabling(p); else void toggle.mutate({ pipeline: p, enabled: on }); }} />
136
+ <Button size="sm" variant="outline" aria-label={`View pipeline ${p.name}`} onClick={() => setViewing(p)}>View</Button>
137
+ <Button size="sm" variant="ghost" aria-label={`Delete pipeline ${p.name}`} onClick={() => setDeleting(p)}>Delete</Button>
138
+ </li>
139
+ ))}
140
+ </ul>
141
+
142
+ <FormSheet open={creating} onOpenChange={setCreating} title="New pipeline" description="Saved switched off. Turn it on from the list when you are ready."
143
+ footer={(<><Button variant="ghost" onClick={() => setCreating(false)}>Cancel</Button><Button loading={create.loading} disabled={!name.trim()} onClick={() => void create.mutate(undefined)}>Create pipeline</Button></>)}>
144
+ <div className="flex flex-col gap-3">
145
+ <div className="flex flex-col gap-1"><Label htmlFor="wbp-name">Pipeline name</Label><Input id="wbp-name" placeholder="Enrich inbound leads" value={name} onChange={(e) => setName(e.target.value)} /></div>
146
+ <div className="flex flex-col gap-1"><Label htmlFor="wbp-start">Starts when</Label>
147
+ <select id="wbp-start" className={selectClass} value={start} onChange={(e) => setStart(e.target.value)}>{PIPELINE_STARTS.map((x) => <option key={x.value} value={x.value}>{x.label}</option>)}</select></div>
148
+ <fieldset className="flex flex-col gap-2 rounded-md border border-border p-3">
149
+ <legend className="px-1 text-xs text-text-color-muted">Steps, in column order</legend>
150
+ {recipeColumns.length === 0 ? <p className="text-sm text-text-color-muted">Add an AI, waterfall or formula column to this workbench first. Its columns become the steps.</p> : null}
151
+ {recipeColumns.map((c) => {
152
+ const usable = stepFor(c) != null;
153
+ return (
154
+ <label key={c.id} className="flex items-center gap-2 text-sm">
155
+ <input type="checkbox" aria-label={c.display_name || c.name} disabled={!usable} checked={picked.includes(c.id)} onChange={(e) => setPicked(e.target.checked ? [...picked, c.id] : picked.filter((x) => x !== c.id))} />
156
+ <span>{c.display_name || c.name}</span>
157
+ <span className="text-xs text-text-color-muted">{c.enrichment_type === "ai" ? "AI" : c.enrichment_type === "waterfall" ? "Waterfall" : "Formula"}{usable ? "" : ", configure it first"}</span>
158
+ </label>
159
+ );
160
+ })}
161
+ </fieldset>
162
+ {create.error ? <ApiError error={create.error} title="Could not create the pipeline" /> : null}
163
+ </div>
164
+ </FormSheet>
165
+
166
+ <PipelineDetail tableId={tableId} pipeline={viewing} onClose={() => setViewing(null)} />
167
+ <ConfirmSpendDialog open={enabling != null} tier="billable" title={`Enable "${enabling?.name ?? ""}"`} description={SPEND_NOTE} confirmLabel="Enable pipeline" confirming={toggle.loading}
168
+ onConfirm={() => enabling && void toggle.mutate({ pipeline: enabling, enabled: true })} onCancel={() => setEnabling(null)} />
169
+ <ConfirmSpendDialog open={deleting != null} tier="destructive" title={`Delete "${deleting?.name ?? ""}"`} description="The pipeline stops running. Rows and columns are not changed."
170
+ confirmLabel="Delete pipeline" confirming={remove.loading} onConfirm={() => deleting && void remove.mutate(deleting)} onCancel={() => setDeleting(null)} />
171
+ </SectionCard>
172
+ );
173
+ }