@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
@@ -12,6 +12,7 @@
12
12
  * 3. exportWorkbenchTable POST .../export { row_ids, list_id | create_new_list + new_list_name, entity_type } (write)
13
13
  * The way finished rows leave the workbench for a list (ListPicker from the lists block).
14
14
  * 4. bulkDeleteWorkbenchRows POST .../rows/bulk-delete { row_ids } (destructive) ConfirmSpendDialog first.
15
+ * 5. "Send to sequence" opens WorkbenchSendToSequence: listSequences, sendWorkbenchRowsToSequence (external, confirmed there; locked rows skipped)
15
16
  */
16
17
 
17
18
  import { useEffect, useState } from "react";
@@ -21,6 +22,7 @@ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
21
22
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
22
23
  import { g8Post } from "@/g8/lib/g8-client";
23
24
  import { ListPicker } from "@/g8/lists/components/list-picker";
25
+ import { WorkbenchSendToSequence } from "./workbench-send-to-sequence";
24
26
  import type { ListSummary } from "@/g8/lists/components/list-picker";
25
27
  import type { CreditQuote } from "../lib/workbench-types";
26
28
 
@@ -29,16 +31,19 @@ export interface WorkbenchRowActionsProps {
29
31
  /** Row ids selected in the grid. The actions are disabled until at least one is selected. */
30
32
  rowIds: number[];
31
33
  entityType?: "contact" | "company";
34
+ /** Which of `rowIds` are still locked. "Send to sequence" skips them. Omit it and none are treated as locked. */
35
+ lockedRowIds?: number[];
32
36
  /** Called after rows were unlocked, deleted or exported so the grid can reload. */
33
37
  onChanged?: (what: "unlocked" | "deleted" | "exported") => void;
34
38
  className?: string;
35
39
  }
36
40
 
37
- export function WorkbenchRowActions({ tableId, rowIds, entityType = "contact", onChanged, className }: WorkbenchRowActionsProps) {
41
+ export function WorkbenchRowActions({ tableId, rowIds, entityType = "contact", lockedRowIds, onChanged, className }: WorkbenchRowActionsProps) {
38
42
  const [quote, setQuote] = useState<CreditQuote | null>(null);
39
43
  const [confirmDelete, setConfirmDelete] = useState(false);
40
44
  const [listId, setListId] = useState<number | null>(null);
41
45
  const [notice, setNotice] = useState<string | null>(null);
46
+ const [sequenceOpen, setSequenceOpen] = useState(false);
42
47
  const none = rowIds.length === 0;
43
48
  // A quote prices one selection: drop it (and its "nothing to unlock" note) when the selection changes.
44
49
  const scope = rowIds.join(",");
@@ -68,8 +73,10 @@ export function WorkbenchRowActions({ tableId, rowIds, entityType = "contact", o
68
73
  <ListPicker value={listId} onChange={pick} type={entityType === "company" ? "companies" : "contacts"} allowCreate />
69
74
  <Button size="sm" variant="outline" disabled={none || listId == null} loading={exportRows.loading} onClick={() => listId != null && void exportRows.mutate(listId)}>Send to list</Button>
70
75
  <Button size="sm" variant="outline" disabled={none} loading={getQuote.loading} onClick={() => void getQuote.mutate(undefined)}>Unlock locked rows</Button>
76
+ <Button size="sm" variant="outline" disabled={none} aria-expanded={sequenceOpen} onClick={() => setSequenceOpen(!sequenceOpen)}>Send to sequence</Button>
71
77
  <Button size="sm" variant="outline" disabled={none} onClick={() => setConfirmDelete(true)}>Delete rows</Button>
72
78
  </div>
79
+ {sequenceOpen && !none ? <WorkbenchSendToSequence tableId={tableId} rowIds={rowIds} lockedRowIds={lockedRowIds} entityType={entityType} onSent={() => onChanged?.("exported")} /> : null}
73
80
  {[getQuote.error, exportRows.error, remove.error, unlock.error].filter(Boolean).map((e, i) => <ApiError key={i} error={e} title="That did not work" />)}
74
81
  {noLocked ? <p role="status" className="text-sm text-muted-foreground">None of the selected rows are locked. Nothing to unlock.</p> : null}
75
82
  {notice ? <p role="status" className="text-sm text-muted-foreground">{notice}</p> : null}
@@ -0,0 +1,191 @@
1
+ "use client";
2
+
3
+ /**
4
+ * WorkbenchSchedule: run something on this workbench on a timer. Shows the current schedule (what runs, how often, whether
5
+ * the scheduler is live, the next and last run) or "No schedule yet"; sets or changes it in a side sheet; removes it with a
6
+ * confirm. Writing a schedule and starting it are separate acts: it is saved stopped unless "Start the schedule now" is on.
7
+ *
8
+ * SDK operations, in order:
9
+ * 1. getWorkbenchSchedule GET /workbench/tables/{table_id}/schedule (read; the API answers 404 when none is set, shown as "No schedule yet")
10
+ * 2. quoteWorkbenchAiRun POST /workbench/tables/{table_id}/ai/validate-credits { all_columns: true } (read, free)
11
+ * the price of one run-all AI pass over the pending rows. Starting an AI schedule REQUIRES its `required_credits` and
12
+ * `quote_expires_at` back as `action_config.approved_max_credits` / `quote_expires_at` (the server refuses to start without them and
13
+ * checks again that the cap still covers the run), so the confirm shows that number
14
+ * 3. setWorkbenchSchedule PUT /workbench/tables/{table_id}/schedule (billable tier: a started "Run the AI columns" schedule charges on every firing)
15
+ * 4. deleteWorkbenchSchedule DELETE /workbench/tables/{table_id}/schedule (destructive, ConfirmSpendDialog; the fastest way to stop recurring spend)
16
+ * 5. listCrmSyncs GET /crm-syncs (read, via useConnectedCrms; the CRM a "Pull records from the CRM" schedule reads)
17
+ *
18
+ * What each action needs in `action_config`, copied from the handler: run_ai needs the quote above when it is started; run_formula takes an
19
+ * optional `column_id` (one formula column, else all of them); crm_import needs `connection_id` and `provider_type` and a `limit` of 1 to 1000.
20
+ * Editing keeps the schedule's existing action_config (the formula it is scoped to, the CRM, the cap), and changing the action starts a fresh one.
21
+ * The cron minute must be a plain number, so a schedule runs at most once an hour. The scheduler runs in UTC.
22
+ */
23
+
24
+ import { useState } from "react";
25
+ import { CalendarClock } from "lucide-react";
26
+ import { Button, Input, Label, Skeleton, Switch } from "@graph8/sdk/ui";
27
+ import { ApiError } from "@/g8/components/api-error";
28
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
29
+ import { EmptyState } from "@/g8/components/empty-state";
30
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
31
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
32
+ import { SectionCard } from "@/g8/components/screen/section-card";
33
+ import { StatusPill } from "@/g8/components/screen/status-pill";
34
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
35
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
36
+ import { g8Delete, g8Get, g8Post, g8Put } from "@/g8/lib/g8-client";
37
+ import { crmLabel, useConnectedCrms } from "../hooks/use-connected-crms";
38
+ import { isNotFound } from "../lib/api-status";
39
+ import { CRON_HINT, MAX_CRM_SCHEDULE_LIMIT, SCHEDULE_ACTIONS, SCHEDULE_FREQUENCIES, describeSchedule, scheduleSpends, schedulerTone, validCron } from "../lib/automation-types";
40
+ import type { WorkbenchSchedule as Schedule } from "../lib/automation-types";
41
+ import type { CreditQuote, WorkbenchColumn } from "../lib/workbench-types";
42
+
43
+ export interface WorkbenchScheduleProps {
44
+ tableId: string;
45
+ /** The table's columns. With them, an action the table cannot run yet (no AI column, no formula column) is disabled; without them every action is offered. */
46
+ columns?: WorkbenchColumn[];
47
+ className?: string;
48
+ }
49
+
50
+ const selectClass = "h-9 w-full rounded-md border border-border bg-background px-2 text-sm";
51
+ const STATE_LABEL: Record<string, string> = { active: "Running on schedule", paused: "Paused", missing: "Not registered with the scheduler", unavailable: "Scheduler unavailable" };
52
+ const when = (iso?: string | null) => (iso ? new Date(iso).toLocaleString() : "");
53
+
54
+ interface Draft {
55
+ action_type: string;
56
+ cadence: string;
57
+ cron: string;
58
+ enabled: boolean;
59
+ /** The action's own settings: the schedule's existing ones when editing, a fresh set when the action changes. */
60
+ config: Record<string, unknown>;
61
+ }
62
+ const BLANK: Draft = { action_type: "run_ai", cadence: "daily", cron: "", enabled: false, config: {} };
63
+ const clampLimit = (n: number) => (Number.isFinite(n) ? Math.min(MAX_CRM_SCHEDULE_LIMIT, Math.max(1, Math.round(n))) : 100);
64
+
65
+ /** The PUT body. `action_config` is sent only when there is something in it, and `extra` (the AI quote) is added on top of the draft's own. */
66
+ function toBody(d: Draft, extra: Record<string, unknown> = {}) {
67
+ const config = { ...d.config, ...extra };
68
+ return {
69
+ action_type: d.action_type, ...(d.cadence === "cron" ? { cron_expression: d.cron.trim() } : { frequency: d.cadence }), enabled: d.enabled,
70
+ ...(Object.keys(config).length ? { action_config: config } : {}),
71
+ };
72
+ }
73
+
74
+ export function WorkbenchSchedule({ tableId, columns, className }: WorkbenchScheduleProps) {
75
+ const path = `/workbench/tables/${tableId}/schedule`;
76
+ const read = useG8Query(async (signal): Promise<Schedule | null> => {
77
+ try { return (await g8Get<Schedule>(path, undefined, { signal })).data; } catch (e) { if (isNotFound(e)) return null; throw e; }
78
+ }, [tableId]);
79
+ const [open, setOpen] = useState(false);
80
+ const [draft, setDraft] = useState<Draft>(BLANK);
81
+ const [quote, setQuote] = useState<CreditQuote | null>(null);
82
+ const [removing, setRemoving] = useState(false);
83
+ const crms = useConnectedCrms({ enabled: open && draft.action_type === "crm_import" });
84
+
85
+ const save = useG8Mutation(async (v: { d: Draft; extra?: Record<string, unknown> }) => (await g8Put(path, toBody(v.d, v.extra))).data, {
86
+ onSuccess: () => { setOpen(false); setQuote(null); void read.refetch(); },
87
+ onError: () => setQuote(null),
88
+ });
89
+ const price = useG8Mutation(async () => (await g8Post<CreditQuote>(`/workbench/tables/${tableId}/ai/validate-credits`, { all_columns: true })).data, { onSuccess: (q) => setQuote(q) });
90
+ const remove = useG8Mutation(async () => { try { await g8Delete(path); } catch (e) { if (!isNotFound(e)) throw e; } }, {
91
+ onSuccess: () => { setRemoving(false); void read.refetch(); },
92
+ });
93
+
94
+ const s = read.data;
95
+ const canAi = !columns || columns.some((c) => c.enrichment_type === "ai");
96
+ const formulas = (columns ?? []).filter((c) => c.column_type === "formula");
97
+ const canFormula = !columns || formulas.length > 0;
98
+ const crm = draft.action_type === "crm_import" ? (crms.connected ?? []).find((c) => c.connection_id === draft.config.connection_id) : undefined;
99
+ const crmReady = draft.action_type !== "crm_import" || (Boolean(draft.config.connection_id) && Boolean(draft.config.provider_type));
100
+ const valid = (draft.cadence !== "cron" || validCron(draft.cron)) && crmReady;
101
+ const edit = () => {
102
+ setDraft(s ? { action_type: s.action_type ?? "run_ai", cadence: s.frequency ?? "cron", cron: s.cron_expression ?? "", enabled: s.enabled === true, config: { ...(s.action_config ?? {}) } } : BLANK);
103
+ save.reset(); price.reset();
104
+ setOpen(true);
105
+ };
106
+ const submit = () => {
107
+ if (!valid || save.loading || price.loading) return;
108
+ if (draft.enabled && scheduleSpends(draft.action_type)) void price.mutate(undefined); // the server wants the quoted cap before it starts an AI schedule
109
+ else void save.mutate({ d: draft });
110
+ };
111
+ const affordable = quote != null && quote.can_run !== false;
112
+ const setAction = (action_type: string) => setDraft({ ...draft, action_type, config: {} });
113
+ const setConfig = (patch: Record<string, unknown>) => setDraft({ ...draft, config: { ...draft.config, ...patch } });
114
+
115
+ return (
116
+ <SectionCard className={className} title="Schedule" icon={<CalendarClock aria-hidden />}
117
+ description="Re-run this workbench on a timer, in UTC."
118
+ actions={s ? (<><Button size="sm" variant="outline" onClick={edit}>Edit schedule</Button><Button size="sm" variant="ghost" onClick={() => setRemoving(true)}>Remove schedule</Button></>) : (read.data === null ? <Button size="sm" onClick={edit}>Set a schedule</Button> : undefined)}>
119
+ {read.error ? <ApiError error={read.error} title="Could not load the schedule" onRetry={() => void read.refetch()} /> : null}
120
+ {remove.error ? <ApiError error={remove.error} title="Could not remove the schedule" /> : null}
121
+ {read.loading && read.data === undefined ? <Skeleton className="h-16 w-full" aria-label="Loading schedule" /> : null}
122
+ {!read.loading && !read.error && s === null ? (
123
+ <EmptyState compact icon={<CalendarClock strokeWidth={1.5} />} title="No schedule yet" description="Recompute formulas every night, or refresh AI columns weekly. A new schedule is saved stopped until you start it." />
124
+ ) : null}
125
+ {s ? (
126
+ <div className="flex flex-col gap-3">
127
+ <div className="flex flex-wrap items-center gap-2">
128
+ <p className="text-sm">{describeSchedule(s)}</p>
129
+ <StatusPill tone={s.enabled === true ? schedulerTone(s.scheduler_state) : "neutral"} dot>{s.enabled === true ? (STATE_LABEL[s.scheduler_state ?? ""] ?? "State unknown") : "Stopped"}</StatusPill>
130
+ </div>
131
+ {s.scheduler_error ? <p role="alert" className="text-sm text-destructive">{s.scheduler_error}</p> : null}
132
+ <KeyValueList columns={2} items={[
133
+ { label: "Next run", value: when(s.next_run_at) },
134
+ { label: "Last run", value: when(s.last_run_at) },
135
+ { label: "Last result", value: s.last_outcome ? s.last_outcome.replace(/_/g, " ") : "" },
136
+ { label: "Set by", value: s.updated_by_label || s.owner_label || "" },
137
+ ]} />
138
+ </div>
139
+ ) : null}
140
+
141
+ <FormSheet open={open} onOpenChange={setOpen} title={s ? "Edit schedule" : "Set a schedule"} description="Saved stopped unless you start it. Starting a schedule that runs AI columns charges on every run."
142
+ footer={(<><Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button><Button loading={save.loading || price.loading} disabled={!valid} onClick={submit}>Save schedule</Button></>)}>
143
+ <div className="flex flex-col gap-3">
144
+ <div className="flex flex-col gap-1"><Label htmlFor="wbs-run">Run</Label>
145
+ <select id="wbs-run" className={selectClass} value={draft.action_type} onChange={(e) => setAction(e.target.value)}>
146
+ {SCHEDULE_ACTIONS.map((x) => <option key={x.value} value={x.value} disabled={(x.value === "run_ai" && !canAi) || (x.value === "run_formula" && !canFormula)}>{x.label}</option>)}</select>
147
+ {!canAi || !canFormula ? <p className="text-xs text-text-color-muted">{!canAi ? "Add an AI column to schedule AI runs. " : ""}{!canFormula ? "Add a formula column to schedule formulas." : ""}</p> : null}</div>
148
+ {draft.action_type === "run_formula" && formulas.length > 0 ? (
149
+ <div className="flex flex-col gap-1"><Label htmlFor="wbs-formula">Formula to recompute</Label>
150
+ <select id="wbs-formula" className={selectClass} value={draft.config.column_id == null ? "" : String(draft.config.column_id)}
151
+ onChange={(e) => { const { column_id: _drop, ...rest } = draft.config; setDraft({ ...draft, config: e.target.value ? { ...rest, column_id: Number(e.target.value) } : rest }); }}>
152
+ <option value="">All formulas</option>{formulas.map((c) => <option key={c.id} value={String(c.id)}>{c.display_name || c.name}</option>)}</select></div>
153
+ ) : null}
154
+ {draft.action_type === "crm_import" ? (
155
+ <div className="grid gap-3 sm:grid-cols-2">
156
+ {crms.error ? <ApiError error={crms.error} title="Could not load your CRMs" onRetry={() => void crms.refetch()} className="sm:col-span-2" /> : null}
157
+ {crms.connected && crms.connected.length === 0 ? <p className="text-sm text-text-color-muted sm:col-span-2">No CRM is connected. Connect one in the platform app first.</p> : null}
158
+ <div className="flex flex-col gap-1"><Label htmlFor="wbs-crm">CRM</Label>
159
+ <select id="wbs-crm" className={selectClass} value={crm?.provider ?? ""} disabled={!crms.connected?.length}
160
+ onChange={(e) => { const c = (crms.connected ?? []).find((x) => x.provider === e.target.value); setConfig(c ? { connection_id: c.connection_id, provider_type: c.provider, limit: draft.config.limit ?? 100 } : { connection_id: undefined, provider_type: undefined }); }}>
161
+ <option value="">{crms.connected ? "Choose a CRM" : "Loading..."}</option>{(crms.connected ?? []).map((c) => <option key={c.provider} value={c.provider}>{crmLabel(c.provider)}</option>)}</select></div>
162
+ <div className="flex flex-col gap-1"><Label htmlFor="wbs-limit">Most records per run</Label>
163
+ <Input id="wbs-limit" type="number" min={1} max={MAX_CRM_SCHEDULE_LIMIT} value={Number(draft.config.limit ?? 100)} onChange={(e) => setConfig({ limit: clampLimit(Number(e.target.value)) })} />
164
+ <p className="text-xs text-text-color-muted">1 to {MAX_CRM_SCHEDULE_LIMIT.toLocaleString()} per run.</p></div>
165
+ </div>
166
+ ) : null}
167
+ <div className="flex flex-col gap-1"><Label htmlFor="wbs-freq">How often</Label>
168
+ <select id="wbs-freq" className={selectClass} value={draft.cadence} onChange={(e) => setDraft({ ...draft, cadence: e.target.value })}>
169
+ {SCHEDULE_FREQUENCIES.map((x) => <option key={x.value} value={x.value}>{x.label}</option>)}<option value="cron">Custom (cron)</option></select></div>
170
+ {draft.cadence === "cron" ? (
171
+ <div className="flex flex-col gap-1"><Label htmlFor="wbs-cron">Cron expression</Label><Input id="wbs-cron" placeholder="30 6 * * 1-5" value={draft.cron} onChange={(e) => setDraft({ ...draft, cron: e.target.value })} />
172
+ {draft.cron && !validCron(draft.cron) ? <p role="status" className="text-xs text-text-color-muted">{CRON_HINT}</p> : null}</div>
173
+ ) : null}
174
+ <div className="flex items-center justify-between gap-3"><Label htmlFor="wbs-on">Start the schedule now</Label><Switch id="wbs-on" aria-label="Start the schedule now" checked={draft.enabled} onCheckedChange={(on) => setDraft({ ...draft, enabled: on })} /></div>
175
+ {price.error ? <ApiError error={price.error} title="Could not price the AI run" /> : null}
176
+ {save.error ? <ApiError error={save.error} title="Could not save the schedule" /> : null}
177
+ </div>
178
+ </FormSheet>
179
+
180
+ <ConfirmSpendDialog open={quote != null} tier="billable" title="Start a recurring AI run"
181
+ description={affordable
182
+ ? `This schedule charges up to ${(quote?.required_credits ?? 0).toLocaleString()} credits on every run, for the ${(quote?.estimated_records ?? 0).toLocaleString()} rows waiting now, with nobody in the loop, until you remove or stop it. Each run is checked again against this maximum and refused if it would cost more.`
183
+ : `Not enough credits: one run needs ${(quote?.required_credits ?? 0).toLocaleString()} and the balance is ${(quote?.available_credits ?? 0).toLocaleString()}.`}
184
+ estimatedCredits={quote?.required_credits} confirmLabel={affordable ? "Start schedule" : "Not enough credits"} confirming={save.loading}
185
+ onConfirm={() => { if (quote && affordable) void save.mutate({ d: draft, extra: { approved_max_credits: quote.required_credits, quote_expires_at: quote.quote_expires_at } }); }}
186
+ onCancel={() => setQuote(null)} />
187
+ <ConfirmSpendDialog open={removing} tier="destructive" title="Remove this schedule" description="The workbench stops re-running on a timer. Rows and columns are not changed."
188
+ confirmLabel="Remove schedule" confirming={remove.loading} onConfirm={() => void remove.mutate(undefined)} onCancel={() => setRemoving(false)} />
189
+ </SectionCard>
190
+ );
191
+ }
@@ -16,13 +16,16 @@
16
16
  * 4. getWorkbenchTable GET /workbench/tables/{table_id} (read, via useWorkbenchTable)
17
17
  * 5. Import people / Scrape the web: previewWorkbenchSearchImport, importWorkbenchFromSearch (billable, confirmed),
18
18
  * estimateWorkbenchScraperCredits, runWorkbenchScraper (billable, confirmed), getWorkbenchScraperRun (see workbench-sources.tsx)
19
- * 6. everything inside the grid: see WorkbenchGrid (runs and unlocks are billable and confirmed)
19
+ * 6. everything inside the grid: see WorkbenchGrid (runs and unlocks are billable and confirmed; filter, sort and delete a column)
20
+ * 7. getWorkbenchLockedRowCount GET /workbench/tables/{table_id}/locked-row-count (read; the "Locked rows" tile, 1 credit each to unlock)
21
+ * 8. an opened table has four tabs (`?tab=`): Data (the grid), Automation (WorkbenchAutomation: auto-enrich, schedule, triggers, pipelines),
22
+ * Import (WorkbenchDatafileImport, WorkbenchCrmImport) and Connections (WorkbenchConnections); "Edit details" opens WorkbenchTableSettings
20
23
  */
21
24
 
22
25
  import { useEffect, useRef, useState } from "react";
23
26
  import type { ReactNode } from "react";
24
- import { ArrowLeft, Globe, Plus, Table2, Trash2, UsersRound } from "lucide-react";
25
- import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
27
+ import { ArrowLeft, Globe, Pencil, Plus, Table2, Trash2, UsersRound } from "lucide-react";
28
+ import { Button, Input, Label, Skeleton, Tabs, TabsContent, TabsList, TabsTrigger } from "@graph8/sdk/ui";
26
29
  import { ApiError } from "@/g8/components/api-error";
27
30
  import { useLoadMoreList } from "@/g8/hooks/use-load-more-list";
28
31
  import { byPage } from "@/g8/lib/paging";
@@ -46,6 +49,11 @@ import type { WorkbenchTable } from "../lib/workbench-types";
46
49
  import { WorkbenchGrid } from "./workbench-grid";
47
50
  import { WorkbenchRowDetail } from "./workbench-row-detail";
48
51
  import { ScraperPanel, SearchImportPanel } from "./workbench-sources";
52
+ import { WorkbenchAutomation } from "./workbench-automation";
53
+ import { WorkbenchConnections } from "./workbench-connections";
54
+ import { WorkbenchCrmImport } from "./workbench-crm-import";
55
+ import { WorkbenchDatafileImport } from "./workbench-datafile-import";
56
+ import { WorkbenchTableSettings } from "./workbench-table-settings";
49
57
 
50
58
  export interface WorkbenchScreenProps {
51
59
  toolbarActions?: ReactNode;
@@ -60,6 +68,9 @@ const noun = (t: { entity_type?: string | null }) => (t.entity_type === "company
60
68
  function OpenTable({ tableId, onBack, renderDetailActions, className }: { tableId: string; onBack: () => void; renderDetailActions?: (t: WorkbenchTable) => ReactNode; className?: string }) {
61
69
  const { table, columns, loading, error, refetch } = useWorkbenchTable(tableId);
62
70
  const [source, setSource] = useState<"import" | "scrape" | null>(null);
71
+ const [editing, setEditing] = useState(false);
72
+ const [tabParam, setTab] = useUrlState("tab");
73
+ const tab = ["automation", "import", "connections"].includes(tabParam ?? "") ? (tabParam as string) : "data";
63
74
  const [rowParam, setRowParam] = useUrlState("row");
64
75
  const [view, setView] = useUrlState("view");
65
76
  const full = view === "full";
@@ -67,6 +78,9 @@ function OpenTable({ tableId, onBack, renderDetailActions, className }: { tableI
67
78
  // New rows arrive from outside the grid (import, scraper): remount it so it reloads, and re-read the counts.
68
79
  const [gridKey, setGridKey] = useState(0);
69
80
  const changed = () => { setGridKey((k) => k + 1); void refetch(); };
81
+ // Locked rows cost a credit each to unlock; the count is re-read whenever rows change.
82
+ const locked = useG8Query(async (signal) => (await g8Get<{ locked_count?: number }>(`/workbench/tables/${tableId}/locked-row-count`, undefined, { signal })).data.locked_count ?? null, [tableId, gridKey]);
83
+ const entity = table?.entity_type === "company" ? "company" : "contact";
70
84
  return (
71
85
  <ScreenLayout
72
86
  className={className}
@@ -77,30 +91,49 @@ function OpenTable({ tableId, onBack, renderDetailActions, className }: { tableI
77
91
  actions={table ? (<>
78
92
  <Button size="sm" variant="outline" onClick={() => setSource("import")}><UsersRound className="mr-1 size-4" aria-hidden />Import people</Button>
79
93
  <Button size="sm" variant="outline" onClick={() => setSource("scrape")}><Globe className="mr-1 size-4" aria-hidden />Scrape the web</Button>
94
+ <Button size="sm" variant="ghost" onClick={() => setEditing(true)}><Pencil className="mr-1 size-4" aria-hidden />Edit details</Button>
80
95
  {renderDetailActions?.(table)}
81
96
  </>) : undefined}
82
97
  stats={table ? [
83
98
  { label: "Rows", value: (table.row_count ?? 0).toLocaleString() },
84
99
  { label: "Columns", value: columns.length.toLocaleString() },
85
100
  { label: "Recipe columns", value: columns.filter((c) => c.enrichment_type).length },
101
+ { label: "Locked rows", value: locked.data != null ? locked.data.toLocaleString() : locked.error ? "Unavailable" : "...", hint: "One credit each to unlock" },
86
102
  ] : undefined}
87
103
  >
88
104
  {error ? <ApiError error={error} title="Could not open the workbench" onRetry={() => void refetch()} /> : null}
89
105
  {loading && !table ? <Skeleton className="h-8 w-64" /> : null}
90
- <SplitView
91
- expanded={full} onExpandedChange={(e) => setView(e ? "full" : null)}
92
- list={<WorkbenchGrid key={gridKey} tableId={tableId} activeRowId={rowId} onOpenRow={(r) => setRowParam(String(r.id))} />}
93
- detailOpen={rowId != null && Number.isFinite(rowId)}
94
- onCloseDetail={() => setRowParam(null)}
95
- detailWidth="lg"
96
- detailTitle="Row"
97
- detail={rowId != null ? (
98
- <DetailPanel key={rowId} onExpand={full ? undefined : () => setView("full")} onCollapse={full ? () => setView(null) : undefined} title={`Row ${rowId}`} subtitle={table?.name} onClose={() => setRowParam(null)}>
99
- <WorkbenchRowDetail tableId={tableId} rowId={rowId} columns={columns} entityType={table?.entity_type === "company" ? "company" : "contact"}
100
- onChanged={(what) => { if (what === "deleted") setRowParam(null); changed(); }} />
101
- </DetailPanel>
102
- ) : null}
103
- />
106
+ <Tabs value={tab} onValueChange={(v) => setTab(v === "data" ? null : v)} className="flex flex-col gap-4">
107
+ <TabsList aria-label="Workbench sections">
108
+ <TabsTrigger value="data">Data</TabsTrigger>
109
+ <TabsTrigger value="automation">Automation</TabsTrigger>
110
+ <TabsTrigger value="import">Import</TabsTrigger>
111
+ <TabsTrigger value="connections">Connections</TabsTrigger>
112
+ </TabsList>
113
+ <TabsContent value="data" className="mt-0">
114
+ <SplitView
115
+ expanded={full} onExpandedChange={(e) => setView(e ? "full" : null)}
116
+ list={<WorkbenchGrid key={gridKey} tableId={tableId} activeRowId={rowId} onOpenRow={(r) => setRowParam(String(r.id))} onChanged={() => void locked.refetch()} />}
117
+ detailOpen={rowId != null && Number.isFinite(rowId)}
118
+ onCloseDetail={() => setRowParam(null)}
119
+ detailWidth="lg"
120
+ detailTitle="Row"
121
+ detail={rowId != null ? (
122
+ <DetailPanel key={rowId} onExpand={full ? undefined : () => setView("full")} onCollapse={full ? () => setView(null) : undefined} title={`Row ${rowId}`} subtitle={table?.name} onClose={() => setRowParam(null)}>
123
+ <WorkbenchRowDetail tableId={tableId} rowId={rowId} columns={columns} entityType={table?.entity_type === "company" ? "company" : "contact"}
124
+ onChanged={(what) => { if (what === "deleted") setRowParam(null); changed(); }} />
125
+ </DetailPanel>
126
+ ) : null}
127
+ />
128
+ </TabsContent>
129
+ <TabsContent value="automation" className="mt-0"><WorkbenchAutomation tableId={tableId} columns={columns} entityType={entity} autoEnrich={table?.auto_enrich} onChanged={() => void refetch()} /></TabsContent>
130
+ <TabsContent value="import" className="mt-0 flex flex-col gap-4">
131
+ <WorkbenchDatafileImport tableId={tableId} columns={columns} autoEnrich={table?.auto_enrich} onImported={changed} />
132
+ <WorkbenchCrmImport tableId={tableId} entityType={entity} autoEnrich={table?.auto_enrich} onImported={changed} />
133
+ </TabsContent>
134
+ <TabsContent value="connections" className="mt-0"><WorkbenchConnections tableId={tableId} columns={columns} /></TabsContent>
135
+ </Tabs>
136
+ <WorkbenchTableSettings tableId={tableId} table={table} open={editing} onOpenChange={setEditing} onSaved={() => void refetch()} />
104
137
  <FormSheet open={source === "import"} onOpenChange={(o) => !o && setSource(null)} width="lg" title="Import people from a search" description="Preview for free, then import. Imported rows are locked until you unlock them.">
105
138
  <SearchImportPanel tableId={tableId} onChanged={changed} />
106
139
  </FormSheet>
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ /**
4
+ * WorkbenchSendToSequence: enroll workbench rows in a running sequence. Pick the sequence by name, pick (or create) the list the
5
+ * rows are saved to, then confirm: the dialog counts exactly the people who will be emailed. Locked rows are never sent (their
6
+ * emails are masked), and a selection with nothing sendable disables the button, because the API treats an omitted row list as
7
+ * "every row in the table".
8
+ *
9
+ * SDK operations, in order:
10
+ * 1. listSequences GET /sequences?page&limit=100 (read; pages through all, running sequences with a whole-number id only)
11
+ * 2. listLists / createList via ListPicker (lists block) the list the rows are exported to before enrolling
12
+ * 3. sendWorkbenchRowsToSequence POST /workbench/tables/{table_id}/send-to-sequence { sequence_id, row_ids, list_id }
13
+ * (external: it EMAILS real people on the sequence's schedule; ConfirmSpendDialog with the recipient count)
14
+ *
15
+ * The confirm counts `sendable` and the request sends that same array. A sequence id the API cannot take (not a whole number)
16
+ * is not offered, and a paused or ended sequence is not offered either.
17
+ */
18
+
19
+ import { useMemo, useState } from "react";
20
+ import { Mail } from "lucide-react";
21
+ import { Button, Label, Skeleton } from "@graph8/sdk/ui";
22
+ import { ApiError } from "@/g8/components/api-error";
23
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
24
+ import { EmptyState } from "@/g8/components/empty-state";
25
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
26
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
27
+ import { g8Get, g8Post } from "@/g8/lib/g8-client";
28
+ import { ListPicker } from "@/g8/lists/components/list-picker";
29
+ import type { ListSummary } from "@/g8/lists/components/list-picker";
30
+ import { rowsOf, sequenceAcceptsPeople, sequenceNumber } from "../lib/automation-types";
31
+
32
+ export interface WorkbenchSendToSequenceProps {
33
+ tableId: string;
34
+ /** Row ids selected in the grid. */
35
+ rowIds: number[];
36
+ /** Which of those rows are still locked; they are skipped. */
37
+ lockedRowIds?: number[];
38
+ entityType?: "contact" | "company";
39
+ /** Called after the API accepted the enrollment. */
40
+ onSent?: () => void;
41
+ className?: string;
42
+ }
43
+
44
+ interface SequenceOption { id: number; name: string }
45
+ interface SendResult { exported_count?: number; enrolled_count?: number; message?: string }
46
+
47
+ const MAX_PAGES = 50;
48
+
49
+ async function runningSequences(signal: AbortSignal): Promise<SequenceOption[]> {
50
+ const out: SequenceOption[] = [];
51
+ for (let page = 1; page <= MAX_PAGES; page++) {
52
+ const env = await g8Get("/sequences", { page, limit: 100 }, { signal });
53
+ const rows = rowsOf<{ id?: string | number; name?: string | null; status?: string | null }>(env.data);
54
+ for (const s of rows) {
55
+ const id = sequenceNumber(s.id);
56
+ if (id != null && sequenceAcceptsPeople(s.status)) out.push({ id, name: s.name || "Untitled sequence" });
57
+ }
58
+ if (!(env as { pagination?: { has_next?: boolean } }).pagination?.has_next || rows.length === 0) break;
59
+ }
60
+ return out;
61
+ }
62
+
63
+ export function WorkbenchSendToSequence({ tableId, rowIds, lockedRowIds = [], entityType = "contact", onSent, className }: WorkbenchSendToSequenceProps) {
64
+ const sequences = useG8Query(runningSequences, []);
65
+ const [sequenceId, setSequenceId] = useState("");
66
+ const [list, setList] = useState<ListSummary | null>(null);
67
+ const [confirming, setConfirming] = useState(false);
68
+ const [result, setResult] = useState<SendResult | null>(null);
69
+
70
+ const locked = useMemo(() => new Set(lockedRowIds), [lockedRowIds]);
71
+ const sendable = useMemo(() => rowIds.filter((id) => !locked.has(id)), [rowIds, locked]);
72
+ const skipped = rowIds.length - sendable.length;
73
+ const chosen = (sequences.data ?? []).find((s) => String(s.id) === sequenceId);
74
+
75
+ const send = useG8Mutation(
76
+ async (v: { sequence: SequenceOption; ids: number[]; listId: number }) =>
77
+ (await g8Post<SendResult>(`/workbench/tables/${tableId}/send-to-sequence`, { sequence_id: v.sequence.id, row_ids: v.ids, list_id: v.listId })).data,
78
+ { onSuccess: (r) => { setConfirming(false); setResult(r); onSent?.(); }, onError: () => setConfirming(false) },
79
+ );
80
+ const ready = chosen != null && list != null && sendable.length > 0;
81
+
82
+ return (
83
+ <div role="group" aria-label="Enroll in a sequence" className={`flex flex-col gap-3 rounded-md border border-border p-3 ${className ?? ""}`}>
84
+ <p className="flex items-center gap-2 text-sm font-medium"><Mail className="size-4 text-text-color-muted" aria-hidden />Enroll in a sequence</p>
85
+ {sequences.error ? <ApiError error={sequences.error} title="Could not load sequences" onRetry={() => void sequences.refetch()} /> : null}
86
+ {sequences.loading && !sequences.data ? <Skeleton className="h-9 w-full" aria-label="Loading sequences" /> : null}
87
+ {sequences.data && sequences.data.length === 0 ? <EmptyState compact title="No running sequences" description="Launch a sequence first, then enroll workbench rows in it." /> : null}
88
+ {sequences.data && sequences.data.length > 0 ? (
89
+ <>
90
+ <div className="flex flex-col gap-1">
91
+ <Label htmlFor={`wb-seq-${tableId}`}>Sequence</Label>
92
+ <select id={`wb-seq-${tableId}`} className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm" value={sequenceId} onChange={(e) => { setSequenceId(e.target.value); setResult(null); }}>
93
+ <option value="">Choose a sequence</option>
94
+ {sequences.data.map((s) => <option key={s.id} value={String(s.id)}>{s.name}</option>)}
95
+ </select>
96
+ </div>
97
+ <div className="flex flex-col gap-1">
98
+ <Label>Save these people to</Label>
99
+ <ListPicker value={list?.id ?? null} onChange={(l) => { setList(l); setResult(null); }} type={entityType === "company" ? "companies" : "contacts"} allowCreate />
100
+ </div>
101
+ </>
102
+ ) : null}
103
+ {rowIds.length === 0 ? <p className="text-xs text-text-color-muted">Select rows first.</p> : null}
104
+ {rowIds.length > 0 && sendable.length === 0 ? <p className="text-xs text-text-color-muted">Every selected row is locked. Unlock them first to enroll them.</p> : null}
105
+ {sendable.length > 0 && skipped > 0 ? <p className="text-xs text-text-color-muted">{skipped} locked {skipped === 1 ? "row is" : "rows are"} skipped. Unlock {skipped === 1 ? "it" : "them"} first to include {skipped === 1 ? "it" : "them"}.</p> : null}
106
+ {send.error ? <ApiError error={send.error} title="Could not enroll the rows" /> : null}
107
+ {result ? <p role="status" className="text-sm text-text-color-muted">Enrolled {result.enrolled_count ?? 0} of {result.exported_count ?? sendable.length} people in the sequence.{result.message ? ` ${result.message}` : ""}</p> : null}
108
+ <div><Button size="sm" disabled={!ready} loading={send.loading} onClick={() => setConfirming(true)}>Enroll in sequence</Button></div>
109
+
110
+ <ConfirmSpendDialog open={confirming && ready} tier="external" title={`Enroll ${sendable.length.toLocaleString()} ${sendable.length === 1 ? "row" : "rows"} in "${chosen?.name ?? ""}"`}
111
+ description={`The rows are saved to "${list?.title ?? ""}", then each person is enrolled and will be emailed on the sequence's schedule. Locked rows are skipped. Once a message is out it cannot be recalled.`}
112
+ recipients={sendable.length} confirmLabel="Enroll rows" confirming={send.loading}
113
+ onConfirm={() => { if (chosen && list) void send.mutate({ sequence: chosen, ids: sendable, listId: list.id }); }} onCancel={() => setConfirming(false)} />
114
+ </div>
115
+ );
116
+ }
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ /**
4
+ * WorkbenchTableSettings: rename a workbench or change its description, in a side sheet. Sends only the fields that changed. The
5
+ * handler ignores a null field but applies an empty string, so a cleared description is sent as "" (a null would silently keep the old one).
6
+ *
7
+ * SDK operations:
8
+ * 1. updateWorkbenchTable PUT /workbench/tables/{table_id} { name?, description? } (write, undoable by editing again)
9
+ */
10
+
11
+ import { useEffect, useState } from "react";
12
+ import { Button, Input, Label, Textarea } from "@graph8/sdk/ui";
13
+ import { ApiError } from "@/g8/components/api-error";
14
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
15
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
16
+ import { g8Put } from "@/g8/lib/g8-client";
17
+ import type { WorkbenchTable } from "../lib/workbench-types";
18
+
19
+ export interface WorkbenchTableSettingsProps {
20
+ tableId: string;
21
+ /** The loaded table; the form is seeded from it only once it is loaded. */
22
+ table: Pick<WorkbenchTable, "name" | "description"> | null | undefined;
23
+ open: boolean;
24
+ onOpenChange: (open: boolean) => void;
25
+ /** Called after a save so the caller can re-read the table. */
26
+ onSaved?: () => void;
27
+ }
28
+
29
+ export function WorkbenchTableSettings({ tableId, table, open, onOpenChange, onSaved }: WorkbenchTableSettingsProps) {
30
+ const [name, setName] = useState("");
31
+ const [description, setDescription] = useState("");
32
+ useEffect(() => { if (open && table) { setName(table.name ?? ""); setDescription(table.description ?? ""); } }, [open, table?.name, table?.description]); // eslint-disable-line react-hooks/exhaustive-deps
33
+
34
+ const originalName = table?.name ?? "";
35
+ const originalDescription = table?.description ?? "";
36
+ const nameChanged = name.trim() !== originalName && name.trim().length > 0;
37
+ const descChanged = description.trim() !== originalDescription;
38
+ const save = useG8Mutation(async () => (await g8Put(`/workbench/tables/${tableId}`, { ...(nameChanged ? { name: name.trim() } : {}), ...(descChanged ? { description: description.trim() } : {}) })).data, {
39
+ onSuccess: () => { onOpenChange(false); onSaved?.(); },
40
+ });
41
+
42
+ return (
43
+ <FormSheet open={open} onOpenChange={onOpenChange} title="Workbench details" description="Rename it or change what it is for."
44
+ footer={(<><Button variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button><Button loading={save.loading} disabled={!table || name.trim().length === 0 || (!nameChanged && !descChanged)} onClick={() => void save.mutate(undefined)}>Save details</Button></>)}>
45
+ <div className="flex flex-col gap-3">
46
+ <div className="flex flex-col gap-1"><Label htmlFor="wb-set-name">Name</Label><Input id="wb-set-name" value={name} onChange={(e) => setName(e.target.value)} /></div>
47
+ <div className="flex flex-col gap-1"><Label htmlFor="wb-set-desc">Description</Label><Textarea id="wb-set-desc" rows={3} value={description} onChange={(e) => setDescription(e.target.value)} />
48
+ </div>
49
+ {save.error ? <ApiError error={save.error} title="Could not save the details" /> : null}
50
+ </div>
51
+ </FormSheet>
52
+ );
53
+ }