@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
@@ -10,10 +10,15 @@
10
10
  * read once more with ?include_provider_errors=true when nothing was enriched or some records failed
11
11
  * -> { status, total, completed, successful_enrichments, failed_enrichments, total_credits_used, warnings, provider_errors }
12
12
  * 2. cancelEnrichmentJob POST /enrichment/jobs/{job_id}/cancel (write, free; records already done keep their data and charge)
13
+ * 3. pauseEnrichmentJob POST /enrichment/jobs/{job_id}/pause (write, free; stops further charges until resumed)
14
+ * 4. resumeEnrichmentJob POST /enrichment/jobs/{job_id}/resume (billable, ConfirmSpendDialog; carries on with, and charges for, the records not yet reached)
15
+ * The state shown is always the server's: after Pause or Resume the job is read again, never assumed.
13
16
  */
14
17
 
18
+ import { useState } from "react";
15
19
  import { Button } from "@graph8/sdk/ui";
16
20
  import { ApiError } from "@/g8/components/api-error";
21
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
17
22
  import { KeyValueList } from "@/g8/components/screen/key-value-list";
18
23
  import { SectionCard } from "@/g8/components/screen/section-card";
19
24
  import { StatusPill, humanizeStatus, toneForStatus } from "@/g8/components/screen/status-pill";
@@ -37,6 +42,9 @@ export function EnrichmentJobDetail({ jobId, onDone, className }: EnrichmentJobD
37
42
  { intervalMs: 2000, isTerminal: (j) => isJobTerminal(j.status), onDone },
38
43
  );
39
44
  const cancel = useG8Mutation(() => g8Post(`/enrichment/jobs/${jobId}/cancel`, {}));
45
+ const [resuming, setResuming] = useState(false);
46
+ const pause = useG8Mutation(() => g8Post(`/enrichment/jobs/${jobId}/pause`, {}), { onSuccess: () => job.start() });
47
+ const resume = useG8Mutation(() => g8Post(`/enrichment/jobs/${jobId}/resume`, {}), { onSuccess: () => { setResuming(false); job.start(); }, onError: () => setResuming(false) });
40
48
  const j = job.status;
41
49
  const problems = job.done && j != null && ((j.successful_enrichments ?? 0) === 0 || (j.failed_enrichments ?? 0) > 0);
42
50
  const errors = useG8Query(
@@ -45,6 +53,8 @@ export function EnrichmentJobDetail({ jobId, onDone, className }: EnrichmentJobD
45
53
  { enabled: problems },
46
54
  );
47
55
  const total = j?.total ?? 0;
56
+ const state = String(j?.status ?? "").toLowerCase();
57
+ const remaining = Math.max(0, total - (j?.completed ?? 0));
48
58
  const pct = total ? Math.min(100, Math.round(((j?.completed ?? 0) / total) * 100)) : 0;
49
59
 
50
60
  if (job.error && !j) return <ApiError error={job.error} title="Could not read the job" onRetry={() => job.start()} />;
@@ -52,10 +62,15 @@ export function EnrichmentJobDetail({ jobId, onDone, className }: EnrichmentJobD
52
62
  <div className={`flex flex-col gap-4 ${className ?? ""}`} role="status">
53
63
  <div className="flex items-center gap-2">
54
64
  {j ? <StatusPill tone={toneForStatus(j.status)} dot>{humanizeStatus(j.status)}</StatusPill> : <span className="text-sm text-muted-foreground">Loading...</span>}
65
+ {!job.done && state === "paused" ? <Button size="sm" onClick={() => setResuming(true)}>Resume job</Button> : null}
66
+ {!job.done && (state === "running" || state === "pending") ? <Button size="sm" variant="outline" loading={pause.loading} onClick={() => void pause.mutate(undefined)}>Pause job</Button> : null}
55
67
  {!job.done && j ? <Button size="sm" variant="outline" loading={cancel.loading} onClick={() => void cancel.mutate(undefined)}>Cancel job</Button> : null}
56
68
  </div>
57
69
  <div className="h-2 overflow-hidden rounded bg-muted" aria-label="Progress"><div className="h-full bg-primary" style={{ width: `${job.done ? 100 : pct}%` }} /></div>
70
+ {j?.paused_at && state === "paused" ? <p className="text-sm text-muted-foreground">Paused {new Date(j.paused_at).toLocaleString()}. Nothing more is enriched or charged until you resume.</p> : null}
58
71
  {cancel.error ? <ApiError error={cancel.error} title="Could not cancel" /> : null}
72
+ {pause.error ? <ApiError error={pause.error} title="Could not pause" /> : null}
73
+ {resume.error ? <ApiError error={resume.error} title="Could not resume" /> : null}
59
74
  <SectionCard title="Result">
60
75
  <KeyValueList columns={2} items={[
61
76
  { label: "Job id", value: jobId, mono: true, wide: true },
@@ -73,6 +88,9 @@ export function EnrichmentJobDetail({ jobId, onDone, className }: EnrichmentJobD
73
88
  </ul>
74
89
  </SectionCard>
75
90
  ) : null}
91
+ <ConfirmSpendDialog open={resuming} tier="billable" title="Resume this job" affected={remaining}
92
+ description="The job carries on with the records it had not reached and charges for them as it goes. Records already enriched are not charged again. The API does not quote a total for a resume, so no credit figure is shown."
93
+ confirmLabel="Resume" confirming={resume.loading} onConfirm={() => void resume.mutate(undefined)} onCancel={() => setResuming(false)} />
76
94
  </div>
77
95
  );
78
96
  }
@@ -7,10 +7,15 @@
7
7
  * - Look up: find one person or company by email, LinkedIn, name and domain (2 credits only when found) and verify
8
8
  * an email address (1 credit). Nothing is written to a record without a tick.
9
9
  * - Pipelines: the saved provider waterfalls of a list, create and delete them.
10
+ * - List pipelines: ordered chains of steps run over a whole list (templates, build, price, run, watch, stop, edit, delete).
11
+ * - Auto-enrichment: what a list enriches by itself as records are added, and the workspace-wide switch and merge rule.
12
+ * - Jobs: running and recent jobs with live progress; pause, resume or cancel one.
13
+ * - Activity: how much ran, how often it worked, which fields it filled.
10
14
  * - Providers: which providers graph8 funds, which need your own key, and what each action costs.
11
15
  *
12
16
  * Composes: ScreenLayout, StatStrip, SplitView, DetailPanel, SectionCard, KeyValueList, StatusPill (screen kit),
13
- * BulkEnrichPanel, WaterfallConfig, EnrichButton, EmailVerifyBadge, ContactTable / ContactDetail (crm), ListPicker (lists).
17
+ * BulkEnrichPanel, WaterfallConfig, ListPipelines, AutoEnrichmentConfigs, AutoEnrichmentSettings, EnrichmentActivity, EnrichButton,
18
+ * EmailVerifyBadge, ContactTable / ContactDetail (crm), ListPicker (lists). Deep link from a list: `?tab=list-pipelines&list=<list id>`.
14
19
  * Provider keys are added in graph8 settings (no API), so the Providers tab links out for them.
15
20
  *
16
21
  * SDK operations (inside the parts it composes): listEnrichmentProviders, preflightEnrichment (read),
@@ -48,7 +53,11 @@ import { CREDITS_PER_EMAIL_VERIFICATION, CREDITS_PER_FOUND_LOOKUP } from "../lib
48
53
  import { BulkEnrichPanel } from "./bulk-enrich-panel";
49
54
  import { EmailVerifyBadge } from "./email-verify-badge";
50
55
  import { EnrichButton } from "./enrich-button";
56
+ import { AutoEnrichmentConfigs } from "./auto-enrichment-configs";
57
+ import { AutoEnrichmentSettings } from "./auto-enrichment-settings";
58
+ import { EnrichmentActivity } from "./enrichment-activity";
51
59
  import { EnrichmentJobDetail } from "./enrichment-job-detail";
60
+ import { ListPipelines } from "./list-pipelines";
52
61
  import { WaterfallConfig } from "./waterfall-config";
53
62
  import type { WaterfallConfigSummary } from "../lib/enrichment-types";
54
63
  import { useBrand, brandUrl } from "@/g8/hooks/use-brand";
@@ -63,7 +72,7 @@ export interface EnrichmentScreenProps {
63
72
  className?: string;
64
73
  }
65
74
 
66
- const TABS = [{ id: "list", label: "Enrich a list" }, { id: "lookup", label: "Look up" }, { id: "pipelines", label: "Pipelines" }, { id: "jobs", label: "Jobs" }, { id: "providers", label: "Providers" }];
75
+ const TABS = [{ id: "list", label: "Enrich a list" }, { id: "lookup", label: "Look up" }, { id: "pipelines", label: "Pipelines" }, { id: "list-pipelines", label: "List pipelines" }, { id: "auto", label: "Auto-enrichment" }, { id: "jobs", label: "Jobs" }, { id: "activity", label: "Activity" }, { id: "providers", label: "Providers" }];
67
76
  const FIELD = "flex flex-col gap-1";
68
77
 
69
78
 
@@ -267,13 +276,14 @@ export function EnrichmentScreen({ toolbarActions, renderDetailActions, extraDet
267
276
  const catalog = useProviders(tab === "providers");
268
277
  const [jobParam, setJobParam] = useUrlState("job");
269
278
  const [pipelineParam, setPipelineParam] = useUrlState("pipeline");
279
+ const [, setListPipelineParam] = useUrlState("lp");
270
280
  const [view, setView] = useUrlState("view");
271
281
  const full = view === "full";
272
282
  const [jobs, setJobs] = useState<string[]>([]);
273
283
  useEffect(() => setJobs(loadJobs()), []);
274
284
  const started = (id: string) => setJobs((cur) => { const next = [id, ...cur.filter((x) => x !== id)]; rememberJobs(next); return next; });
275
285
  const listId = listParam ? Number(listParam) : null;
276
- const pick = (id: number) => { setListParam(String(id)); setPicked([]); setRunShown(false); setOpened(null); setPipelineParam(null); };
286
+ const pick = (id: number) => { setListParam(String(id)); setPicked([]); setRunShown(false); setOpened(null); setPipelineParam(null); setListPipelineParam(null); };
277
287
 
278
288
  const stats = tab === "providers" && catalog.providers.length > 0 ? [
279
289
  { label: "Providers", value: catalog.providers.length },
@@ -287,7 +297,28 @@ export function EnrichmentScreen({ toolbarActions, renderDetailActions, extraDet
287
297
  const openPipeline = (loaded.listId === listId ? loaded.configs : []).find((c) => c.column_id === pipelineParam);
288
298
  if (tab === "lookup") body = <LookupTab />;
289
299
  else if (tab === "jobs") body = <JobsTab jobs={jobs} jobParam={jobParam} setJobParam={setJobParam} />;
300
+ else if (tab === "activity") body = <EnrichmentActivity />;
290
301
  else if (tab === "providers") body = <ProvidersTab settingsUrl={providerSettingsUrl} />;
302
+ else if (tab === "list-pipelines" || tab === "auto") {
303
+ body = (
304
+ <div className="grid gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]">
305
+ <ListRail listId={listId} onPick={pick} />
306
+ {listId == null ? (
307
+ <EmptyState icon={<Sparkles className="size-6" aria-hidden />} title="Choose a list" description={tab === "auto" ? "Automatic enrichment belongs to a list. Pick one to see what it runs on new records." : "Pipelines belong to a list. Pick one to see, build and run them."} />
308
+ ) : tab === "auto" ? (
309
+ <div className="flex min-w-0 flex-col gap-6">
310
+ <AutoEnrichmentConfigs key={listId} listId={listId} />
311
+ <section aria-label="Workspace settings" className="flex flex-col gap-2">
312
+ <h3 className="text-sm font-medium">Workspace-wide settings</h3>
313
+ <AutoEnrichmentSettings />
314
+ </section>
315
+ </div>
316
+ ) : (
317
+ <div className="min-w-0"><ListPipelines key={listId} listId={listId} /></div>
318
+ )}
319
+ </div>
320
+ );
321
+ }
291
322
  else {
292
323
  body = (
293
324
  <div className="grid gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]">
@@ -0,0 +1,97 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ListPipelines: the pipelines of one list (ordered chains of enrichment steps run over the whole list) with the open
5
+ * pipeline beside them. Pick a ready-made template or build one, then open it to price it, run it, watch it and stop it.
6
+ *
7
+ * Composes PipelineDetail (run, stop, on/off, delete, history), PipelineEditor (create, edit) and PipelineTemplates.
8
+ * The open pipeline is in the URL (`?lp=<pipeline id>`) so a refresh or a shared link comes back to it.
9
+ *
10
+ * SDK operations (the list read here; the rest are inside the parts above):
11
+ * 1. listListPipelines GET /enrichment/lists/{list_id}/pipelines (read, free) every pipeline of the list with its latest run
12
+ *
13
+ * The list is not paged by the API. A run that is active is re-read after it settles, so on/off and last-run state
14
+ * always come from the server, never from what this screen assumes.
15
+ */
16
+
17
+ import { useState } from "react";
18
+ import { Layers, Plus, Sparkles } from "lucide-react";
19
+ import { Button } from "@graph8/sdk/ui";
20
+ import { ApiError } from "@/g8/components/api-error";
21
+ import { EmptyState } from "@/g8/components/empty-state";
22
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
23
+ import { SectionCard } from "@/g8/components/screen/section-card";
24
+ import { SplitView } from "@/g8/components/screen/split-view";
25
+ import { StatusPill, humanizeStatus, toneForStatus } from "@/g8/components/screen/status-pill";
26
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
27
+ import { useUrlState } from "@/g8/hooks/use-url-state";
28
+ import { g8Get } from "@/g8/lib/g8-client";
29
+ import { plural } from "@/g8/lib/plural";
30
+ import { enabledSteps, itemsOf } from "../lib/pipeline-types";
31
+ import type { ListPipeline } from "../lib/pipeline-types";
32
+ import { PipelineDetail } from "./pipeline-detail";
33
+ import { PipelineEditor } from "./pipeline-editor";
34
+ import { PipelineTemplates } from "./pipeline-templates";
35
+
36
+ export interface ListPipelinesProps {
37
+ listId: number;
38
+ /** Called after any change, for example to refresh a sibling table. */
39
+ onChanged?: () => void;
40
+ className?: string;
41
+ }
42
+
43
+ export function ListPipelines({ listId, onChanged, className }: ListPipelinesProps) {
44
+ const [open, setOpen] = useUrlState("lp");
45
+ const [view, setView] = useUrlState("view");
46
+ const [editing, setEditing] = useState<ListPipeline | "new" | null>(null);
47
+ const [templates, setTemplates] = useState(false);
48
+ const pipelines = useG8Query(async (signal) => itemsOf<ListPipeline>((await g8Get(`/enrichment/lists/${listId}/pipelines`, undefined, { signal })).data), [listId]);
49
+ const rows = pipelines.data ?? [];
50
+ const selected = rows.find((p) => p.id === open);
51
+ const full = view === "full";
52
+ const changed = () => { void pipelines.refetch(); onChanged?.(); };
53
+
54
+ const list = (
55
+ <SectionCard title="List pipelines" description="Each one runs its steps, in order, over every record on this list."
56
+ actions={<div className="flex gap-2"><Button size="sm" variant="outline" onClick={() => setTemplates(true)}><Sparkles className="mr-1 size-4" aria-hidden />Templates</Button><Button size="sm" onClick={() => setEditing("new")}><Plus className="mr-1 size-4" aria-hidden />New pipeline</Button></div>} flush>
57
+ {pipelines.error ? <div className="p-3"><ApiError error={pipelines.error} title="Could not load pipelines" onRetry={() => void pipelines.refetch()} /></div> : null}
58
+ {!pipelines.error && pipelines.loading && !pipelines.data ? <p className="p-4 text-sm text-muted-foreground">Loading pipelines...</p> : null}
59
+ {!pipelines.error && pipelines.data && rows.length === 0 ? (
60
+ <EmptyState icon={<Layers className="size-6" aria-hidden />} title="No pipelines on this list" description="Start from a template, or build your own from the enrichment steps this list can use."
61
+ action={<Button size="sm" onClick={() => setTemplates(true)}>Start from a template</Button>} />
62
+ ) : null}
63
+ <ul className="divide-y">{(pipelines.error ? [] : rows).map((p) => (
64
+ <li key={p.id}>
65
+ <button type="button" aria-label={`Open pipeline ${p.name}`} className={`flex w-full items-center justify-between gap-3 px-4 py-3 text-left ${p.id === open ? "bg-muted/50" : ""}`} onClick={() => setOpen(p.id)}>
66
+ <span className="min-w-0">
67
+ <span className="block truncate text-sm font-medium">{p.name}</span>
68
+ <span className="block text-xs text-muted-foreground">{plural(enabledSteps(p).length, "step")}</span>
69
+ </span>
70
+ <span className="flex shrink-0 items-center gap-2">
71
+ {p.last_run ? <StatusPill tone={toneForStatus(p.last_run.status)} dot>{humanizeStatus(p.last_run.status)}</StatusPill> : null}
72
+ <StatusPill tone={p.enabled ? "positive" : "neutral"}>{p.enabled ? "On" : "Off"}</StatusPill>
73
+ </span>
74
+ </button>
75
+ </li>
76
+ ))}</ul>
77
+ </SectionCard>
78
+ );
79
+
80
+ return (
81
+ <div className={className}>
82
+ <SplitView list={list} expanded={full} onExpandedChange={(e) => setView(e ? "full" : null)} detailOpen={selected != null} onCloseDetail={() => setOpen(null)} detailTitle="Pipeline"
83
+ detail={selected ? (
84
+ <DetailPanel key={selected.id} title={selected.name} subtitle={plural(selected.steps.length, "step")} onClose={() => setOpen(null)}
85
+ onExpand={full ? undefined : () => setView("full")} onCollapse={full ? () => setView(null) : undefined}
86
+ badges={<StatusPill tone={selected.enabled ? "positive" : "neutral"} dot>{selected.enabled ? "On" : "Off"}</StatusPill>}>
87
+ <PipelineDetail key={selected.id} listId={listId} pipeline={selected} onChanged={changed} onEdit={() => setEditing(selected)} onDeleted={() => { setOpen(null); changed(); }} />
88
+ </DetailPanel>
89
+ ) : null} />
90
+ {editing ? (
91
+ <PipelineEditor key={editing === "new" ? "new" : editing.id} listId={listId} pipeline={editing === "new" ? undefined : editing} open onOpenChange={(o) => { if (!o) setEditing(null); }}
92
+ onSaved={(saved) => { setOpen(saved.id); changed(); }} />
93
+ ) : null}
94
+ <PipelineTemplates listId={listId} open={templates} onOpenChange={setTemplates} existing={rows} onAdded={(p) => { setOpen(p.id); changed(); }} />
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,214 @@
1
+ "use client";
2
+
3
+ /**
4
+ * PipelineDetail: one list pipeline in full, and everything you can do to it: run it (priced first), watch the run,
5
+ * stop it, turn it on or off, edit it, delete it, and read its past runs.
6
+ *
7
+ * SDK operations, in order:
8
+ * 1. listPipelineStepOptions GET /enrichment/lists/{list_id}/pipeline-step-options (read) names the steps
9
+ * 2. estimateListPipeline POST /enrichment/lists/{list_id}/pipelines/{id}/estimate (read, FREE) prices the run for THIS list
10
+ * -> { can_run, required_credits (an upper bound: run conditions are ignored), available_credits, estimated_evaluations, steps[], reason }
11
+ * 3. runListPipeline POST /enrichment/lists/{list_id}/pipelines/{id}/runs (billable, ConfirmSpendDialog)
12
+ * charges per record per step; also turns a pipeline that is off on, as the app does
13
+ * 4. getPipelineRun GET /enrichment/lists/pipeline-runs/{run_id} (read) polled with useJobPoll
14
+ * 5. cancelListPipelineRun POST /enrichment/lists/{list_id}/pipelines/{id}/cancel (write, free) the step in flight finishes
15
+ * 6. listListPipelineRuns GET /enrichment/lists/{list_id}/pipelines/{id}/runs (read) the latest runs, newest first
16
+ * 7. setPipelineEnabled POST /enrichment/lists/{list_id}/pipelines/{id}/enabled { enabled } (billable tier, ConfirmSpendDialog)
17
+ * a flag only: in this API a pipeline runs when you run it, so turning it on does not start or charge anything
18
+ * 8. deleteListPipeline DELETE /enrichment/lists/{list_id}/pipelines/{id} (destructive, ConfirmSpendDialog; 409 while running)
19
+ *
20
+ * The price in the run confirm is the estimate's own number for this exact list and pipeline, never a constant. The
21
+ * confirm opens only after the estimate has loaded; a run the estimate refuses (`can_run: false`) shows the API's
22
+ * reason and never reaches the confirm.
23
+ */
24
+
25
+ import { useState } from "react";
26
+ import { Pencil, Play, Square, Trash2 } from "lucide-react";
27
+ import { Button } from "@graph8/sdk/ui";
28
+ import { ApiError } from "@/g8/components/api-error";
29
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
30
+ import { useConfirmAction } from "@/g8/components/confirmed-action";
31
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
32
+ import { SectionCard } from "@/g8/components/screen/section-card";
33
+ import { StatusPill, humanizeStatus, toneForStatus } 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 { useJobPoll } from "@/g8/hooks/use-job-poll";
37
+ import { g8Delete, g8Get, g8Post } from "@/g8/lib/g8-client";
38
+ import { plural } from "@/g8/lib/plural";
39
+ import { enabledSteps, itemsOf, percentOf, runBlockedReason, runIsActive, skipReasonLabel, stepLabel } from "../lib/pipeline-types";
40
+ import type { ListPipeline, PipelineEstimate, PipelineRun, StepOption, StepProgress } from "../lib/pipeline-types";
41
+
42
+ export interface PipelineDetailProps {
43
+ listId: number;
44
+ pipeline: ListPipeline;
45
+ /** Reload the pipelines after anything changed (a run started or finished, on/off, stop). */
46
+ onChanged: () => void;
47
+ onEdit: () => void;
48
+ /** Called after the pipeline was deleted. */
49
+ onDeleted: () => void;
50
+ className?: string;
51
+ }
52
+
53
+ const base = (listId: number, id: string) => `/enrichment/lists/${listId}/pipelines/${id}`;
54
+ const when = (iso?: string | null) => (iso ? new Date(iso).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "");
55
+
56
+ function StepRows({ progress }: { progress: StepProgress[] }) {
57
+ if (progress.length === 0) return <p className="text-sm text-muted-foreground">No step has started yet.</p>;
58
+ return (
59
+ <ul className="flex flex-col gap-3" aria-label="Run progress">
60
+ {progress.map((p, i) => {
61
+ const pct = percentOf(p.processed_records, p.total_records);
62
+ const skipped = Object.entries(p.skipped_by_reason ?? {}).filter(([, n]) => n > 0);
63
+ return (
64
+ <li key={`${p.step_index}-${i}`} className="flex flex-col gap-1">
65
+ <div className="flex items-center justify-between gap-2 text-sm">
66
+ <span className="truncate font-medium">{p.name || `Step ${p.step_index + 1}`}</span>
67
+ <span className="flex items-center gap-2 text-xs text-muted-foreground">
68
+ <span className="tabular-nums">{pct == null ? "-" : `${(p.processed_records ?? 0).toLocaleString()} of ${(p.total_records ?? 0).toLocaleString()}`}</span>
69
+ {p.status ? <StatusPill tone={toneForStatus(p.status)} dot>{humanizeStatus(p.status)}</StatusPill> : null}
70
+ </span>
71
+ </div>
72
+ <div className="h-1.5 overflow-hidden rounded bg-muted" aria-label={`Step ${p.step_index + 1} progress`}><div className="h-full bg-primary" style={{ width: `${pct ?? 0}%` }} /></div>
73
+ <p className="text-xs text-muted-foreground tabular-nums">{(p.successful ?? 0).toLocaleString()} enriched, {(p.failed ?? 0).toLocaleString()} failed{skipped.map(([r, n]) => `, ${n.toLocaleString()} ${skipReasonLabel(r)}`).join("")}</p>
74
+ {p.note ? <p className="text-xs text-muted-foreground">{p.note}</p> : null}
75
+ </li>
76
+ );
77
+ })}
78
+ </ul>
79
+ );
80
+ }
81
+
82
+ /** Polls one active run (a fresh instance per run id) and shows its steps; reports when it is no longer running. */
83
+ function RunStatus({ runId, initial, onSettled }: { runId: string; initial?: PipelineRun | null; onSettled: () => void }) {
84
+ const poll = useJobPoll<PipelineRun>(
85
+ async (signal) => (await g8Get<PipelineRun>(`/enrichment/lists/pipeline-runs/${runId}`, undefined, { signal })).data,
86
+ { intervalMs: 2000, isTerminal: (r) => !runIsActive(r.status), onDone: onSettled },
87
+ );
88
+ const run = poll.status ?? initial ?? undefined;
89
+ if (poll.error && !run) return <ApiError error={poll.error} title="Could not read the run" onRetry={() => poll.start()} />;
90
+ return (
91
+ <div className="flex flex-col gap-2" role="status">
92
+ <div className="flex items-center gap-2 text-sm">
93
+ {run ? <StatusPill tone={toneForStatus(run.status)} dot>{humanizeStatus(run.status)}</StatusPill> : <span className="text-muted-foreground">Starting...</span>}
94
+ {run ? <span className="text-xs text-muted-foreground">Step {Math.min(run.current_index + 1, run.total_steps)} of {run.total_steps}</span> : null}
95
+ </div>
96
+ {poll.error && run ? <p role="alert" className="text-xs text-destructive">Could not refresh the run. Showing the last known progress.</p> : null}
97
+ {run?.error ? <p role="alert" className="text-sm text-destructive">{run.error}</p> : null}
98
+ <StepRows progress={run?.step_progress ?? []} />
99
+ </div>
100
+ );
101
+ }
102
+
103
+ export function PipelineDetail({ listId, pipeline, onChanged, onEdit, onDeleted, className }: PipelineDetailProps) {
104
+ const [historyKey, setHistoryKey] = useState(0);
105
+ const [started, setStarted] = useState<string | null>(null);
106
+ const [blocked, setBlocked] = useState<string | null>(null);
107
+ const { ask, dialog, failure } = useConfirmAction();
108
+ const options = useG8Query(async (signal) => itemsOf<StepOption>((await g8Get(`/enrichment/lists/${listId}/pipeline-step-options`, undefined, { signal })).data), [listId], { key: ["enrichment", "step-options", listId], staleTime: 30_000 });
109
+ const history = useG8Query(async (signal) => itemsOf<PipelineRun>((await g8Get(`${base(listId, pipeline.id)}/runs`, undefined, { signal })).data, "runs"), [listId, pipeline.id, historyKey]);
110
+ const price = useG8Mutation(async () => (await g8Post<PipelineEstimate>(`${base(listId, pipeline.id)}/estimate`, {})).data);
111
+ const stop = useG8Mutation(async () => g8Post(`${base(listId, pipeline.id)}/cancel`, {}), { onSuccess: () => { setStarted(null); setHistoryKey((k) => k + 1); onChanged(); } });
112
+
113
+ const menu = options.data ?? [];
114
+ const active = runIsActive(pipeline.last_run?.status) || started != null;
115
+ const runId = started ?? (runIsActive(pipeline.last_run?.status) ? pipeline.last_run?.run_id ?? null : null);
116
+ const live = enabledSteps(pipeline);
117
+ const reason = active ? "A run is in progress." : runBlockedReason(pipeline);
118
+ const settled = () => { setStarted(null); setHistoryKey((k) => k + 1); onChanged(); };
119
+
120
+ const startRun = (est: PipelineEstimate) => ask({
121
+ tier: "billable",
122
+ title: `Run "${pipeline.name}" over this list`,
123
+ estimatedCredits: est.required_credits,
124
+ confirmLabel: "Run pipeline",
125
+ description: (
126
+ <div className="flex flex-col gap-2">
127
+ <p>{plural(est.estimated_evaluations, "record check")} across {plural(est.steps.length, "step")}. This is an upper bound: run conditions are ignored when pricing, so the real charge can be lower.</p>
128
+ <ul className="list-disc pl-5">{est.steps.map((s) => <li key={s.step_id}>{s.name}: {plural(s.estimated_records, "record")}, up to {plural(s.required_credits, "credit")}</li>)}</ul>
129
+ <p>Your balance is {est.available_credits.toLocaleString()} credits{est.available_credits < est.required_credits ? ", which may not cover every step: the run stops cleanly when credits run out" : ""}.</p>
130
+ {pipeline.enabled ? null : <p>This also turns the pipeline on.</p>}
131
+ </div>
132
+ ),
133
+ run: async () => { const res = await g8Post<PipelineRun>(`${base(listId, pipeline.id)}/runs`, {}); setStarted(res.data.run_id); onChanged(); },
134
+ });
135
+ const onRun = async () => {
136
+ setBlocked(null);
137
+ const est = await price.mutate(undefined);
138
+ if (!est) return;
139
+ if (est.can_run) startRun(est);
140
+ else setBlocked(est.reason || "The API did not give a reason this run cannot start.");
141
+ };
142
+ const setEnabled = (next: boolean) => ask({
143
+ tier: "billable",
144
+ title: `${next ? "Turn on" : "Turn off"} "${pipeline.name}"`,
145
+ confirmLabel: next ? "Turn on" : "Turn off",
146
+ description: next ? "Marks the pipeline as on. It does not start a run and charges nothing by itself: a run starts when you press Run, and shows its price first." : "Marks the pipeline as off. Running it again turns it back on. A run already in progress is not stopped.",
147
+ run: () => g8Post(`${base(listId, pipeline.id)}/enabled`, { enabled: next }),
148
+ onDone: onChanged,
149
+ });
150
+ const remove = () => ask({
151
+ tier: "destructive",
152
+ title: `Delete pipeline "${pipeline.name}"`,
153
+ description: "Its steps and run history go. Values it already wrote stay on the records. A running pipeline cannot be deleted.",
154
+ affected: 1,
155
+ tolerateStatus: [404],
156
+ run: () => g8Delete(base(listId, pipeline.id)),
157
+ onDone: onDeleted,
158
+ });
159
+
160
+ return (
161
+ <div className={`flex flex-col gap-4 ${className ?? ""}`}>
162
+ <div className="flex flex-wrap items-center gap-2">
163
+ <Button size="sm" disabled={reason != null || price.loading} loading={price.loading} onClick={() => void onRun()}><Play className="mr-1 size-4" aria-hidden />{price.loading ? "Pricing..." : "Run"}</Button>
164
+ {active ? <Button size="sm" variant="outline" loading={stop.loading} onClick={() => void stop.mutate(undefined)}><Square className="mr-1 size-4" aria-hidden />Stop run</Button> : null}
165
+ <Button size="sm" variant="outline" disabled={active} onClick={() => setEnabled(!pipeline.enabled)}>{pipeline.enabled ? "Turn off" : "Turn on"}</Button>
166
+ <Button size="sm" variant="outline" disabled={active} onClick={onEdit}><Pencil className="mr-1 size-4" aria-hidden />Edit</Button>
167
+ <Button size="sm" variant="ghost" disabled={active} onClick={remove}><Trash2 className="mr-1 size-4" aria-hidden />Delete</Button>
168
+ </div>
169
+ {reason ? <p className="text-xs text-muted-foreground">{reason}</p> : null}
170
+ {blocked ? <p role="alert" className="text-sm text-destructive">Cannot run: {blocked}</p> : null}
171
+ {price.error ? <ApiError error={price.error} title="Could not price the run" /> : null}
172
+ {stop.error ? <ApiError error={stop.error} title="Could not stop the run" /> : null}
173
+ {failure ? <ApiError error={failure} title="That did not go through" /> : null}
174
+
175
+ <SectionCard title="Steps" description={`${plural(live.length, "step")} will run, in this order.`}>
176
+ <ol className="flex flex-col gap-2" aria-label="Pipeline steps">
177
+ {pipeline.steps.map((s, i) => (
178
+ <li key={s.id} className="flex flex-col gap-0.5 text-sm">
179
+ <span className="flex items-center gap-2"><span className="w-5 text-muted-foreground">{i + 1}.</span><span className="font-medium">{stepLabel(s, menu)}</span>{s.enabled === false ? <StatusPill tone="neutral">Off</StatusPill> : null}</span>
180
+ <span className="pl-7 text-xs text-muted-foreground">{s.skip_existing_values === false ? "Overwrites existing values" : "Fills only empty values"}{s.skip_recently_enriched === false ? "" : ", skips records enriched recently"}{s.run_condition ? `. Runs only when: ${s.run_condition}` : ""}</span>
181
+ </li>
182
+ ))}
183
+ </ol>
184
+ </SectionCard>
185
+
186
+ {runId ? (
187
+ <SectionCard title="Current run"><RunStatus key={runId} runId={runId} initial={pipeline.last_run} onSettled={settled} /></SectionCard>
188
+ ) : pipeline.last_run ? (
189
+ <SectionCard title="Last run" description={when(pipeline.last_run.started_at)}>
190
+ <div className="flex flex-col gap-2">
191
+ <StatusPill tone={toneForStatus(pipeline.last_run.status)} dot>{humanizeStatus(pipeline.last_run.status)}</StatusPill>
192
+ {pipeline.last_run.error ? <p role="alert" className="text-sm text-destructive">{pipeline.last_run.error}</p> : null}
193
+ <StepRows progress={pipeline.last_run.step_progress ?? []} />
194
+ </div>
195
+ </SectionCard>
196
+ ) : <p className="text-sm text-muted-foreground">This pipeline has not run yet.</p>}
197
+
198
+ <SectionCard title="Run history" description="The latest runs, newest first." flush>
199
+ {history.error ? <div className="p-3"><ApiError error={history.error} title="Could not load run history" onRetry={() => void history.refetch()} /></div> : null}
200
+ {!history.error && history.loading && !history.data ? <p className="p-4 text-sm text-muted-foreground">Loading runs...</p> : null}
201
+ {!history.error && history.data && history.data.length === 0 ? <p className="p-4 text-sm text-muted-foreground">No finished runs yet.</p> : null}
202
+ <ul className="divide-y">{(history.error ? [] : history.data ?? []).map((r) => (
203
+ <li key={r.run_id} className="flex items-center justify-between gap-2 px-4 py-2 text-sm">
204
+ <span>{when(r.started_at) || "Run"}</span>
205
+ <StatusPill tone={toneForStatus(r.status)} dot>{humanizeStatus(r.status)}</StatusPill>
206
+ </li>
207
+ ))}</ul>
208
+ </SectionCard>
209
+
210
+ <KeyValueList columns={2} items={[{ label: "State", value: pipeline.enabled ? "On" : "Off" }, { label: "Created", value: when(pipeline.created_at) }]} />
211
+ <ConfirmSpendDialog {...dialog} />
212
+ </div>
213
+ );
214
+ }
@@ -0,0 +1,144 @@
1
+ "use client";
2
+
3
+ /**
4
+ * PipelineEditor: create or edit a list pipeline (an ordered chain of enrichment steps that runs over a whole list)
5
+ * in a side sheet. Steps are picked from the configs this list can already use (AI groups and provider waterfalls);
6
+ * each step can be turned off, told to fill only empty values, or to skip recently enriched records.
7
+ *
8
+ * SDK operations, in order:
9
+ * 1. listPipelineStepOptions GET /enrichment/lists/{list_id}/pipeline-step-options (read, free) the menu steps are picked from
10
+ * 2. createListPipeline POST /enrichment/lists/{list_id}/pipelines (billable tier; saved TURNED OFF, nothing runs)
11
+ * 3. updateListPipeline PUT /enrichment/lists/{list_id}/pipelines/{pipeline_id} (billable tier; replaces the steps, keeps on/off)
12
+ * Both saves go through ConfirmSpendDialog. Saving never runs the pipeline and charges nothing in this API: a run is a
13
+ * separate, priced step (see PipelineDetail). The dialog says so, because its generic tier note talks about credits.
14
+ *
15
+ * An edit seeds from the pipeline row the list already loaded (a full record) and sends the whole step list back with
16
+ * every field of every step kept (run conditions and builder state included), changing only what was edited. Saving
17
+ * is disabled until the step menu has loaded, and while nothing changed.
18
+ */
19
+
20
+ import { useState } from "react";
21
+ import { ArrowDown, ArrowUp, Trash2 } from "lucide-react";
22
+ import { Button, Input, Label } from "@graph8/sdk/ui";
23
+ import { ApiError } from "@/g8/components/api-error";
24
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
25
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
26
+ import { StatusPill } from "@/g8/components/screen/status-pill";
27
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
28
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
29
+ import { g8Get, g8Post, g8Put } from "@/g8/lib/g8-client";
30
+ import { plural } from "@/g8/lib/plural";
31
+ import { itemsOf, moveItem, pipelineChanged, savePayload, stepFromOption, stepLabel } from "../lib/pipeline-types";
32
+ import type { ListPipeline, PipelineStep, StepOption } from "../lib/pipeline-types";
33
+
34
+ export interface PipelineEditorProps {
35
+ listId: number;
36
+ /** The pipeline to edit; omit to create a new one. */
37
+ pipeline?: ListPipeline;
38
+ open: boolean;
39
+ onOpenChange: (open: boolean) => void;
40
+ /** Called with the saved pipeline after the API accepted it. */
41
+ onSaved?: (pipeline: ListPipeline) => void;
42
+ }
43
+
44
+ const MAX_STEPS = 20;
45
+ const toggle = "flex items-center gap-2 text-xs text-muted-foreground";
46
+
47
+ export function PipelineEditor({ listId, pipeline, open, onOpenChange, onSaved }: PipelineEditorProps) {
48
+ const [name, setName] = useState(pipeline?.name ?? "");
49
+ const [steps, setSteps] = useState<PipelineStep[]>(() => (pipeline?.steps ?? []).map((s) => ({ ...s })));
50
+ const [confirming, setConfirming] = useState(false);
51
+ const [pick, setPick] = useState("");
52
+ const options = useG8Query(async (signal) => itemsOf<StepOption>((await g8Get(`/enrichment/lists/${listId}/pipeline-step-options`, undefined, { signal })).data), [listId], { enabled: open, key: ["enrichment", "step-options", listId], staleTime: 30_000 });
53
+ const menu = options.data ?? [];
54
+ const editing = pipeline != null;
55
+ const save = useG8Mutation(
56
+ async () => {
57
+ const body = savePayload(name, steps);
58
+ const res = editing
59
+ ? await g8Put<ListPipeline>(`/enrichment/lists/${listId}/pipelines/${pipeline.id}`, body)
60
+ : await g8Post<ListPipeline>(`/enrichment/lists/${listId}/pipelines`, body);
61
+ return res.data;
62
+ },
63
+ { onSuccess: (saved) => { setConfirming(false); onSaved?.(saved); onOpenChange(false); }, onError: () => setConfirming(false) },
64
+ );
65
+
66
+ const available = menu.filter((o) => !steps.some((s) => s.config_ref === o.config_ref && s.type === o.type));
67
+ const patch = (i: number, change: Partial<PipelineStep>) => setSteps((cur) => cur.map((s, at) => (at === i ? { ...s, ...change } : s)));
68
+ const addStep = () => {
69
+ const option = available.find((o) => `${o.type}:${o.config_ref}` === pick);
70
+ if (!option) return;
71
+ setSteps((cur) => [...cur, stepFromOption(option, cur.map((s) => s.id))]);
72
+ setPick("");
73
+ };
74
+ const valid = name.trim().length > 0 && name.trim().length <= 255 && steps.length > 0 && steps.length <= MAX_STEPS;
75
+ const dirty = editing ? pipelineChanged(pipeline, name, steps) : true;
76
+ const ready = valid && dirty && !options.loading && !options.error;
77
+
78
+ return (
79
+ <FormSheet
80
+ open={open}
81
+ onOpenChange={onOpenChange}
82
+ title={editing ? `Edit ${pipeline.name}` : "New list pipeline"}
83
+ description="Steps run in order over every record on the list. Saving does not run anything."
84
+ width="lg"
85
+ footer={<>
86
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
87
+ <Button type="button" disabled={!ready} onClick={() => setConfirming(true)}>{editing ? "Save changes" : "Save pipeline"}</Button>
88
+ </>}
89
+ >
90
+ <div className="flex flex-col gap-1">
91
+ <Label htmlFor="pl-name">Name</Label>
92
+ <Input id="pl-name" value={name} maxLength={255} onChange={(e) => setName(e.target.value)} />
93
+ </div>
94
+ {options.error ? <ApiError error={options.error} title="Could not load the steps you can use" onRetry={() => void options.refetch()} /> : null}
95
+ <section className="flex flex-col gap-2" aria-label="Steps">
96
+ <h3 className="text-sm font-medium">Steps</h3>
97
+ {steps.length === 0 ? <p className="text-sm text-muted-foreground">Add at least one step.</p> : null}
98
+ <ol className="flex flex-col gap-2">
99
+ {steps.map((s, i) => (
100
+ <li key={s.id} className="rounded-md border p-3" data-testid="editor-step">
101
+ <div className="flex items-center gap-2">
102
+ <span className="w-5 text-sm text-muted-foreground">{i + 1}.</span>
103
+ <span className="min-w-0 flex-1 truncate text-sm font-medium">{stepLabel(s, menu)}</span>
104
+ <StatusPill tone="neutral">{s.type === "ai" ? "AI" : "Providers"}</StatusPill>
105
+ <Button type="button" variant="ghost" size="sm" aria-label={`Move step ${i + 1} up`} disabled={i === 0} onClick={() => setSteps((cur) => moveItem(cur, i, -1))}><ArrowUp className="size-4" aria-hidden /></Button>
106
+ <Button type="button" variant="ghost" size="sm" aria-label={`Move step ${i + 1} down`} disabled={i === steps.length - 1} onClick={() => setSteps((cur) => moveItem(cur, i, 1))}><ArrowDown className="size-4" aria-hidden /></Button>
107
+ <Button type="button" variant="ghost" size="sm" aria-label={`Remove step ${i + 1}`} onClick={() => setSteps((cur) => cur.filter((_, at) => at !== i))}><Trash2 className="size-4" aria-hidden /></Button>
108
+ </div>
109
+ <div className="mt-2 flex flex-col gap-1 pl-7">
110
+ <label className={toggle}><input type="checkbox" checked={s.enabled !== false} onChange={(e) => patch(i, { enabled: e.target.checked })} /> Run this step</label>
111
+ <label className={toggle}><input type="checkbox" checked={s.skip_existing_values !== false} onChange={(e) => patch(i, { skip_existing_values: e.target.checked })} /> Fill only empty values</label>
112
+ <label className={toggle}><input type="checkbox" checked={s.skip_recently_enriched !== false} onChange={(e) => patch(i, { skip_recently_enriched: e.target.checked })} /> Skip records enriched recently</label>
113
+ {s.run_condition ? <p className="text-xs text-muted-foreground">Runs only when: {s.run_condition}</p> : null}
114
+ </div>
115
+ </li>
116
+ ))}
117
+ </ol>
118
+ <div className="flex items-end gap-2">
119
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
120
+ <Label htmlFor="pl-add">Add a step</Label>
121
+ <select id="pl-add" className="h-9 rounded-md border bg-background px-2 text-sm" value={pick} disabled={options.loading || steps.length >= MAX_STEPS} onChange={(e) => setPick(e.target.value)}>
122
+ <option value="">{options.loading ? "Loading steps..." : available.length ? "Choose a step" : "No more steps to add"}</option>
123
+ {available.map((o) => <option key={`${o.type}:${o.config_ref}`} value={`${o.type}:${o.config_ref}`}>{o.name}{o.is_qualification ? " (qualification)" : ""}</option>)}
124
+ </select>
125
+ </div>
126
+ <Button type="button" variant="outline" disabled={!pick} onClick={addStep}>Add step</Button>
127
+ </div>
128
+ {!options.loading && !options.error && menu.length === 0 ? <p className="text-xs text-muted-foreground">This list has no enrichment configs yet. Create a provider waterfall in the Pipelines tab, then come back.</p> : null}
129
+ {steps.length >= MAX_STEPS ? <p className="text-xs text-muted-foreground">A pipeline can have at most {MAX_STEPS} steps.</p> : null}
130
+ </section>
131
+ {save.error ? <ApiError error={save.error} title="Could not save the pipeline" /> : null}
132
+ <ConfirmSpendDialog
133
+ open={confirming}
134
+ tier="billable"
135
+ title={editing ? `Save changes to "${name.trim()}"` : `Save pipeline "${name.trim()}"`}
136
+ description={`${editing ? `Replaces the pipeline's steps with the ${plural(steps.length, "step")} shown; it stays ${pipeline.enabled ? "on" : "off"}. ` : `Saves ${plural(steps.length, "step")}, turned off. `}Saving does not run the pipeline and charges nothing. You see the price before every run.`}
137
+ confirmLabel="Save"
138
+ confirming={save.loading}
139
+ onConfirm={() => void save.mutate(undefined)}
140
+ onCancel={() => setConfirming(false)}
141
+ />
142
+ </FormSheet>
143
+ );
144
+ }