@graph8/sdk 0.361.0 → 0.370.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 (388) hide show
  1. package/README.md +14 -10
  2. package/blocks/_foundation/README.md +172 -1
  3. package/blocks/_foundation/block.json +234 -13
  4. package/blocks/_foundation/components/api-error.tsx +71 -25
  5. package/blocks/_foundation/components/brand-provider.tsx +23 -0
  6. package/blocks/_foundation/components/confirmed-action.tsx +90 -0
  7. package/blocks/_foundation/components/connect-graph8.tsx +280 -0
  8. package/blocks/_foundation/components/empty-state.tsx +39 -7
  9. package/blocks/_foundation/components/screen/bar-list.tsx +71 -0
  10. package/blocks/_foundation/components/screen/detail-panel.tsx +114 -0
  11. package/blocks/_foundation/components/screen/entity-avatar.tsx +56 -0
  12. package/blocks/_foundation/components/screen/form-sheet.tsx +73 -0
  13. package/blocks/_foundation/components/screen/index.ts +22 -0
  14. package/blocks/_foundation/components/screen/key-value-list.tsx +65 -0
  15. package/blocks/_foundation/components/screen/record-link.tsx +76 -0
  16. package/blocks/_foundation/components/screen/screen-layout.tsx +124 -0
  17. package/blocks/_foundation/components/screen/section-card.tsx +47 -0
  18. package/blocks/_foundation/components/screen/sparkline.tsx +77 -0
  19. package/blocks/_foundation/components/screen/split-view.tsx +125 -0
  20. package/blocks/_foundation/components/screen/stat-strip.tsx +121 -0
  21. package/blocks/_foundation/components/screen/status-pill.tsx +104 -0
  22. package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
  23. package/blocks/_foundation/components/screen/time-series-chart.tsx +32 -0
  24. package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
  25. package/blocks/_foundation/components/screen/toolbar.tsx +144 -0
  26. package/blocks/_foundation/hooks/use-brand.ts +52 -0
  27. package/blocks/_foundation/hooks/use-g8-mutation.ts +23 -0
  28. package/blocks/_foundation/hooks/use-g8-query.ts +127 -1
  29. package/blocks/_foundation/hooks/use-paginated.ts +120 -0
  30. package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
  31. package/blocks/_foundation/hooks/use-url-state.ts +74 -0
  32. package/blocks/_foundation/lib/g8-cache.ts +454 -0
  33. package/blocks/_foundation/lib/g8-client.ts +1 -1
  34. package/blocks/_foundation/lib/plural.ts +23 -0
  35. package/blocks/_foundation/server/connect-route.ts +15 -0
  36. package/blocks/_foundation/server/connect.ts +127 -0
  37. package/blocks/_foundation/server/local-key.ts +70 -0
  38. package/blocks/_foundation/server/proxy.ts +103 -3
  39. package/blocks/_foundation/server/route.ts +16 -4
  40. package/blocks/agents/README.md +14 -0
  41. package/blocks/agents/block.json +33 -1
  42. package/blocks/agents/components/agent-builder.tsx +13 -8
  43. package/blocks/agents/components/agent-knowledge-panel.tsx +2 -1
  44. package/blocks/agents/components/agent-run-detail.tsx +102 -0
  45. package/blocks/agents/components/agent-runs.tsx +16 -7
  46. package/blocks/agents/components/agents-screen.tsx +184 -0
  47. package/blocks/analytics/README.md +13 -0
  48. package/blocks/analytics/block.json +45 -1
  49. package/blocks/analytics/components/analytics-screen.tsx +209 -0
  50. package/blocks/analytics/components/leaderboard.tsx +14 -7
  51. package/blocks/analytics/components/report-builder.tsx +23 -9
  52. package/blocks/analytics/components/report-detail.tsx +84 -0
  53. package/blocks/analytics/components/trend-chart.tsx +62 -0
  54. package/blocks/appointments/README.md +21 -1
  55. package/blocks/appointments/block.json +43 -2
  56. package/blocks/appointments/components/availability-editor.tsx +17 -5
  57. package/blocks/appointments/components/event-type-editor.tsx +2 -1
  58. package/blocks/appointments/components/event-type-related.tsx +77 -0
  59. package/blocks/appointments/components/meeting-list.tsx +14 -6
  60. package/blocks/appointments/components/meetings-screen.tsx +157 -0
  61. package/blocks/appointments/components/scheduling-screen.tsx +122 -0
  62. package/blocks/appointments/hooks/use-event-types.ts +17 -0
  63. package/blocks/block.schema.json +18 -0
  64. package/blocks/block.types.ts +26 -0
  65. package/blocks/campaigns/README.md +41 -11
  66. package/blocks/campaigns/block.json +105 -42
  67. package/blocks/campaigns/components/campaign-assets.tsx +16 -5
  68. package/blocks/campaigns/components/campaign-launch-step.tsx +102 -19
  69. package/blocks/campaigns/components/campaign-list.tsx +7 -3
  70. package/blocks/campaigns/components/campaign-wizard.tsx +43 -10
  71. package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
  72. package/blocks/campaigns/components/icp-detail.tsx +154 -0
  73. package/blocks/campaigns/components/icp-editor.tsx +15 -15
  74. package/blocks/campaigns/components/persona-detail.tsx +172 -0
  75. package/blocks/campaigns/components/persona-editor.tsx +14 -11
  76. package/blocks/campaigns/lib/campaigns-utils.ts +134 -1
  77. package/blocks/campaigns/recipes/attach-audience-and-launch.ts +16 -3
  78. package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +8 -4
  79. package/blocks/coach/README.md +10 -0
  80. package/blocks/coach/block.json +16 -1
  81. package/blocks/coach/components/coach-screen.tsx +222 -0
  82. package/blocks/copilot/README.md +13 -0
  83. package/blocks/copilot/block.json +14 -1
  84. package/blocks/copilot/components/copilot-panel.tsx +14 -5
  85. package/blocks/copilot/components/copilot-screen.tsx +148 -0
  86. package/blocks/copilot/components/copilot-team-chat.tsx +7 -5
  87. package/blocks/crm/README.md +28 -0
  88. package/blocks/crm/block.json +58 -2
  89. package/blocks/crm/components/companies-screen.tsx +345 -0
  90. package/blocks/crm/components/company-detail.tsx +8 -6
  91. package/blocks/crm/components/company-table.tsx +11 -4
  92. package/blocks/crm/components/contact-detail.tsx +6 -3
  93. package/blocks/crm/components/contact-table.tsx +10 -3
  94. package/blocks/crm/components/contacts-screen.tsx +359 -0
  95. package/blocks/crm/components/dedupe-review.tsx +7 -5
  96. package/blocks/crm/components/import-wizard.tsx +4 -2
  97. package/blocks/crm/components/notes-panel.tsx +4 -4
  98. package/blocks/crm/components/record-form.tsx +5 -3
  99. package/blocks/crm/components/task-list.tsx +4 -4
  100. package/blocks/crm/components/tasks-screen.tsx +379 -0
  101. package/blocks/crm/hooks/use-custom-fields.ts +6 -1
  102. package/blocks/crm/hooks/use-page-query.ts +7 -1
  103. package/blocks/crm/lib/crm-types.ts +12 -2
  104. package/blocks/crm/lib/screen-parts.tsx +105 -0
  105. package/blocks/deals/README.md +34 -3
  106. package/blocks/deals/block.json +45 -4
  107. package/blocks/deals/components/deal-detail.tsx +27 -13
  108. package/blocks/deals/components/deal-related.tsx +87 -0
  109. package/blocks/deals/components/deals-screen.tsx +298 -0
  110. package/blocks/deals/components/pipeline-board.tsx +4 -2
  111. package/blocks/deals/components/stage-editor.tsx +4 -4
  112. package/blocks/deals/hooks/use-default-owner.ts +60 -0
  113. package/blocks/deals/hooks/use-pipeline-deals.ts +5 -1
  114. package/blocks/deals/hooks/use-pipelines.ts +1 -1
  115. package/blocks/deals/lib/deals-types.ts +14 -0
  116. package/blocks/dialer/README.md +34 -2
  117. package/blocks/dialer/block.json +67 -1
  118. package/blocks/dialer/components/call-contact-button.tsx +252 -0
  119. package/blocks/dialer/components/call-log-screen.tsx +205 -0
  120. package/blocks/dialer/components/call-log.tsx +7 -3
  121. package/blocks/dialer/components/call-queue.tsx +1 -1
  122. package/blocks/dialer/components/dialer-screen.tsx +319 -0
  123. package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
  124. package/blocks/dialer/components/live-calls-table.tsx +7 -3
  125. package/blocks/dialer/components/session-start-form.tsx +4 -2
  126. package/blocks/dialer/components/softphone.tsx +3 -1
  127. package/blocks/enrichment/README.md +14 -2
  128. package/blocks/enrichment/block.json +32 -4
  129. package/blocks/enrichment/components/bulk-enrich-panel.tsx +10 -5
  130. package/blocks/enrichment/components/email-verify-badge.tsx +28 -8
  131. package/blocks/enrichment/components/enrich-button.tsx +32 -9
  132. package/blocks/enrichment/components/enrichment-job-detail.tsx +78 -0
  133. package/blocks/enrichment/components/enrichment-screen.tsx +288 -0
  134. package/blocks/enrichment/components/waterfall-config.tsx +39 -10
  135. package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
  136. package/blocks/forms/README.md +12 -0
  137. package/blocks/forms/block.json +39 -17
  138. package/blocks/forms/components/form-builder.tsx +3 -1
  139. package/blocks/forms/components/form-embed.tsx +3 -1
  140. package/blocks/forms/components/forms-screen.tsx +353 -0
  141. package/blocks/forms/components/submissions-table.tsx +16 -6
  142. package/blocks/inbox/README.md +27 -3
  143. package/blocks/inbox/block.json +14 -2
  144. package/blocks/inbox/components/inbox-list.tsx +7 -3
  145. package/blocks/inbox/components/inbox-screen.tsx +294 -27
  146. package/blocks/inbox/components/reply-composer.tsx +65 -9
  147. package/blocks/inbox/components/tag-and-assign.tsx +4 -2
  148. package/blocks/inbox/hooks/use-inbox-thread.ts +1 -1
  149. package/blocks/inbox/hooks/use-inbox-threads.ts +1 -0
  150. package/blocks/inbox/lib/inbox-types.ts +47 -1
  151. package/blocks/inbox/recipes/01-triage-and-reply.ts +12 -2
  152. package/blocks/integrations/README.md +9 -0
  153. package/blocks/integrations/block.json +46 -1
  154. package/blocks/integrations/components/crm-detail.tsx +100 -0
  155. package/blocks/integrations/components/integration-list.tsx +9 -4
  156. package/blocks/integrations/components/integrations-screen.tsx +135 -0
  157. package/blocks/integrations/components/sync-status.tsx +5 -3
  158. package/blocks/integrations/components/webhook-detail.tsx +160 -0
  159. package/blocks/integrations/components/webhook-manager.tsx +13 -8
  160. package/blocks/integrations/lib/integrations-utils.ts +6 -1
  161. package/blocks/intent/README.md +14 -0
  162. package/blocks/intent/block.json +27 -1
  163. package/blocks/intent/components/intent-feed.tsx +5 -2
  164. package/blocks/intent/components/intent-keyword-manager.tsx +8 -4
  165. package/blocks/intent/components/intent-screen.tsx +265 -0
  166. package/blocks/intent/components/keyword-detail.tsx +124 -0
  167. package/blocks/lifecycle/README.md +16 -0
  168. package/blocks/lifecycle/block.json +34 -7
  169. package/blocks/lifecycle/components/account-related.tsx +66 -0
  170. package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
  171. package/blocks/lifecycle/components/lifecycle-screen.tsx +149 -0
  172. package/blocks/lifecycle/lib/lifecycle.ts +1 -1
  173. package/blocks/lists/README.md +13 -0
  174. package/blocks/lists/block.json +39 -1
  175. package/blocks/lists/components/add-to-list-button.tsx +3 -2
  176. package/blocks/lists/components/list-picker.tsx +5 -1
  177. package/blocks/lists/components/list-table.tsx +11 -5
  178. package/blocks/lists/components/lists-screen.tsx +344 -0
  179. package/blocks/lists/components/suppression-manager.tsx +6 -2
  180. package/blocks/lists/components/suppressions-screen.tsx +255 -0
  181. package/blocks/mailboxes/README.md +17 -0
  182. package/blocks/mailboxes/block.json +65 -17
  183. package/blocks/mailboxes/components/connect-mailbox.tsx +23 -13
  184. package/blocks/mailboxes/components/domain-detail.tsx +142 -0
  185. package/blocks/mailboxes/components/domain-purchase.tsx +6 -3
  186. package/blocks/mailboxes/components/mailbox-detail.tsx +156 -0
  187. package/blocks/mailboxes/components/mailboxes-screen.tsx +223 -0
  188. package/blocks/mailboxes/components/warmup-health.tsx +4 -2
  189. package/blocks/mailboxes/lib/mailboxes-utils.ts +22 -1
  190. package/blocks/newsletter/README.md +13 -0
  191. package/blocks/newsletter/block.json +47 -23
  192. package/blocks/newsletter/components/issue-scheduler.tsx +2 -1
  193. package/blocks/newsletter/components/newsletter-screen.tsx +404 -0
  194. package/blocks/newsletter/components/subscriber-table.tsx +12 -6
  195. package/blocks/nurture/README.md +12 -0
  196. package/blocks/nurture/block.json +35 -13
  197. package/blocks/nurture/components/nurture-builder.tsx +3 -1
  198. package/blocks/nurture/components/nurture-enroll.tsx +6 -3
  199. package/blocks/nurture/components/nurture-form.tsx +23 -3
  200. package/blocks/nurture/components/nurture-screen.tsx +283 -0
  201. package/blocks/nurture/lib/nurture-types.ts +1 -1
  202. package/blocks/objects/README.md +17 -0
  203. package/blocks/objects/block.json +29 -16
  204. package/blocks/objects/components/object-not-found.tsx +3 -1
  205. package/blocks/objects/components/object-record-detail.tsx +2 -1
  206. package/blocks/objects/components/object-schema-editor.tsx +3 -1
  207. package/blocks/objects/components/object-table.tsx +13 -3
  208. package/blocks/objects/components/objects-screen.tsx +147 -0
  209. package/blocks/objects/lib/object-types.ts +1 -1
  210. package/blocks/pages/README.md +12 -0
  211. package/blocks/pages/block.json +35 -22
  212. package/blocks/pages/components/brand-kit-editor.tsx +5 -2
  213. package/blocks/pages/components/content-library.tsx +46 -11
  214. package/blocks/pages/components/page-list.tsx +6 -3
  215. package/blocks/pages/components/pages-screen.tsx +154 -0
  216. package/blocks/quotes/README.md +17 -0
  217. package/blocks/quotes/block.json +38 -9
  218. package/blocks/quotes/components/product-picker.tsx +3 -1
  219. package/blocks/quotes/components/quote-list.tsx +7 -3
  220. package/blocks/quotes/components/quote-related.tsx +123 -0
  221. package/blocks/quotes/components/quotes-screen.tsx +169 -0
  222. package/blocks/radar/README.md +10 -0
  223. package/blocks/radar/block.json +41 -1
  224. package/blocks/radar/components/competitor-detail.tsx +117 -0
  225. package/blocks/radar/components/competitor-tracker.tsx +3 -1
  226. package/blocks/radar/components/radar-report.tsx +4 -2
  227. package/blocks/radar/components/radar-screen.tsx +214 -0
  228. package/blocks/radar/components/report-detail.tsx +104 -0
  229. package/blocks/search/README.md +10 -0
  230. package/blocks/search/block.json +16 -1
  231. package/blocks/search/components/filter-builder.tsx +3 -3
  232. package/blocks/search/components/prospect-results.tsx +5 -3
  233. package/blocks/search/components/prospect-search-screen.tsx +235 -0
  234. package/blocks/search/components/saved-searches.tsx +4 -1
  235. package/blocks/search/hooks/use-filter-options.ts +20 -6
  236. package/blocks/search/lib/search-types.ts +2 -0
  237. package/blocks/sequencer/README.md +49 -9
  238. package/blocks/sequencer/block.json +99 -19
  239. package/blocks/sequencer/components/enroll-dialog.tsx +9 -3
  240. package/blocks/sequencer/components/sequence-builder.tsx +211 -46
  241. package/blocks/sequencer/components/sequence-detail.tsx +259 -0
  242. package/blocks/sequencer/components/sequence-enrollments.tsx +114 -0
  243. package/blocks/sequencer/components/sequence-list.tsx +33 -8
  244. package/blocks/sequencer/components/sequence-stats.tsx +3 -2
  245. package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
  246. package/blocks/sequencer/components/sequences-screen.tsx +159 -0
  247. package/blocks/sequencer/components/step-editor.tsx +3 -1
  248. package/blocks/sequencer/lib/sequencer-utils.ts +128 -5
  249. package/blocks/sequencer/recipes/create-multichannel-sequence.ts +33 -5
  250. package/blocks/sequencer/recipes/launch-and-enroll.ts +19 -2
  251. package/blocks/social-ads/README.md +11 -0
  252. package/blocks/social-ads/block.json +101 -2
  253. package/blocks/social-ads/components/ad-campaign-list.tsx +9 -5
  254. package/blocks/social-ads/components/ads-screen.tsx +152 -0
  255. package/blocks/social-ads/components/audience-sync-detail.tsx +188 -0
  256. package/blocks/social-ads/components/audience-sync.tsx +11 -7
  257. package/blocks/social-ads/components/campaign-detail.tsx +133 -0
  258. package/blocks/social-ads/components/creative-detail.tsx +150 -0
  259. package/blocks/social-ads/components/creative-gallery.tsx +15 -5
  260. package/blocks/social-ads/components/post-composer.tsx +3 -1
  261. package/blocks/social-ads/components/post-detail.tsx +160 -0
  262. package/blocks/social-ads/components/social-post-list.tsx +11 -3
  263. package/blocks/social-ads/components/social-screen.tsx +160 -0
  264. package/blocks/visitors/README.md +10 -0
  265. package/blocks/visitors/block.json +25 -1
  266. package/blocks/visitors/components/snippet-install.tsx +3 -1
  267. package/blocks/visitors/components/visitor-company-card.tsx +2 -2
  268. package/blocks/visitors/components/visitor-detail.tsx +105 -0
  269. package/blocks/visitors/components/visitor-feed.tsx +11 -2
  270. package/blocks/visitors/components/visitors-screen.tsx +166 -0
  271. package/blocks/webchat/README.md +10 -0
  272. package/blocks/webchat/block.json +21 -7
  273. package/blocks/webchat/components/chat-widget-config.tsx +7 -4
  274. package/blocks/webchat/components/conversation-list.tsx +7 -3
  275. package/blocks/webchat/components/webchat-screen.tsx +245 -0
  276. package/blocks/workbench/README.md +11 -0
  277. package/blocks/workbench/block.json +48 -1
  278. package/blocks/workbench/components/column-recipe-editor.tsx +4 -2
  279. package/blocks/workbench/components/workbench-grid.tsx +9 -3
  280. package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
  281. package/blocks/workbench/components/workbench-screen.tsx +192 -0
  282. package/blocks/workbench/components/workbench-sources.tsx +194 -0
  283. package/blocks/workflows/README.md +13 -0
  284. package/blocks/workflows/block.json +28 -9
  285. package/blocks/workflows/components/run-history.tsx +31 -9
  286. package/blocks/workflows/components/trigger-config.tsx +3 -1
  287. package/blocks/workflows/components/workflow-editor.tsx +3 -1
  288. package/blocks/workflows/components/workflow-list.tsx +13 -6
  289. package/blocks/workflows/components/workflows-screen.tsx +193 -0
  290. package/dist/cli.js +2785 -1637
  291. package/dist/errors.d.mts +34 -0
  292. package/dist/errors.d.ts +34 -0
  293. package/dist/errors.js +46 -0
  294. package/dist/errors.js.map +1 -0
  295. package/dist/errors.mjs +21 -0
  296. package/dist/errors.mjs.map +1 -0
  297. package/dist/index.browser.js +11 -5
  298. package/dist/index.browser.js.map +1 -1
  299. package/dist/index.browser.mjs +11 -5
  300. package/dist/index.browser.mjs.map +1 -1
  301. package/dist/index.d.mts +164 -25
  302. package/dist/index.d.ts +164 -25
  303. package/dist/index.js +11 -5
  304. package/dist/index.js.map +1 -1
  305. package/dist/index.mjs +11 -5
  306. package/dist/index.mjs.map +1 -1
  307. package/dist/react.browser.js +10 -4
  308. package/dist/react.browser.js.map +1 -1
  309. package/dist/react.browser.mjs +10 -4
  310. package/dist/react.browser.mjs.map +1 -1
  311. package/dist/react.d.mts +142 -5
  312. package/dist/react.d.ts +142 -5
  313. package/dist/react.js +10 -4
  314. package/dist/react.js.map +1 -1
  315. package/dist/react.mjs +10 -4
  316. package/dist/react.mjs.map +1 -1
  317. package/dist/types/blocks/add.d.ts +11 -4
  318. package/dist/types/blocks/brand.d.ts +113 -0
  319. package/dist/types/blocks/compose.d.ts +53 -0
  320. package/dist/types/blocks/create.d.ts +25 -8
  321. package/dist/types/blocks/doctor.d.ts +6 -1
  322. package/dist/types/blocks/flows.d.ts +20 -0
  323. package/dist/types/blocks/navfile.d.ts +35 -0
  324. package/dist/types/blocks/paths.d.ts +4 -0
  325. package/dist/types/blocks/prefixes.d.ts +37 -0
  326. package/dist/types/blocks/registry.d.ts +32 -0
  327. package/dist/types/blocks/screens.d.ts +53 -0
  328. package/dist/types/cli.d.ts +2 -0
  329. package/dist/types/errors.d.ts +33 -0
  330. package/dist/types/http.d.ts +2 -18
  331. package/dist/types/version.d.ts +2 -2
  332. package/docs/agents.md +15 -4
  333. package/docs/api/README.md +2 -2
  334. package/docs/api/by-path.md +1 -0
  335. package/docs/api/desk.d.ts +98 -2
  336. package/docs/api/desk.md +123 -1
  337. package/docs/api/inbox.d.ts +10 -2
  338. package/docs/api/inbox.md +10 -2
  339. package/docs/api/seo.d.ts +19 -1
  340. package/docs/api/seo.md +24 -1
  341. package/package.json +16 -2
  342. package/stacks/6sense.json +41 -16
  343. package/stacks/apollo/pages/companies.tsx.tmpl +50 -0
  344. package/stacks/apollo/pages/contacts.tsx.tmpl +117 -0
  345. package/stacks/apollo.json +118 -36
  346. package/stacks/calendly.json +60 -0
  347. package/stacks/clay/pages/companies.tsx.tmpl +15 -0
  348. package/stacks/clay/pages/contacts.tsx.tmpl +19 -0
  349. package/stacks/clay.json +59 -26
  350. package/stacks/gong.json +81 -35
  351. package/stacks/hootsuite.json +99 -0
  352. package/stacks/hubspot/pages/companies.tsx.tmpl +47 -0
  353. package/stacks/hubspot/pages/contacts.tsx.tmpl +111 -0
  354. package/stacks/hubspot/pages/deals.tsx.tmpl +11 -0
  355. package/stacks/hubspot/pages/inbox.tsx.tmpl +92 -0
  356. package/stacks/hubspot/pages/quotes.tsx.tmpl +11 -0
  357. package/stacks/hubspot.json +167 -66
  358. package/stacks/intercom.json +48 -20
  359. package/stacks/mailchimp.json +52 -33
  360. package/stacks/outreach/pages/contacts.tsx.tmpl +85 -0
  361. package/stacks/outreach.json +88 -25
  362. package/stacks/pipedrive/pages/companies.tsx.tmpl +47 -0
  363. package/stacks/pipedrive/pages/contacts.tsx.tmpl +47 -0
  364. package/stacks/pipedrive/pages/deals.tsx.tmpl +11 -0
  365. package/stacks/pipedrive/pages/inbox.tsx.tmpl +92 -0
  366. package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
  367. package/stacks/pipedrive.json +115 -0
  368. package/stacks/salesforce/pages/companies.tsx.tmpl +47 -0
  369. package/stacks/salesforce/pages/contacts.tsx.tmpl +47 -0
  370. package/stacks/salesforce/pages/deals.tsx.tmpl +11 -0
  371. package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
  372. package/stacks/salesforce.json +81 -31
  373. package/stacks/stack.schema.json +6 -1
  374. package/stacks/zoominfo/pages/companies.tsx.tmpl +15 -0
  375. package/stacks/zoominfo/pages/contacts.tsx.tmpl +19 -0
  376. package/stacks/zoominfo.json +102 -0
  377. package/styles/styles.css +1 -1
  378. package/templates/next-app/README.md.tmpl +55 -8
  379. package/templates/next-app/app/globals.css.tmpl +105 -0
  380. package/templates/next-app/app/layout.tsx.tmpl +18 -4
  381. package/templates/next-app/components/app-shell.tsx.tmpl +248 -23
  382. package/templates/next-app/components/shell/connection-status.tsx.tmpl +109 -0
  383. package/templates/next-app/components/shell/nav-model.ts.tmpl +65 -0
  384. package/templates/next-app/components/shell/quick-jump.tsx.tmpl +131 -0
  385. package/templates/next-app/components/shell/theme-toggle.tsx.tmpl +40 -0
  386. package/templates/next-app/dot-gitignore.tmpl +2 -0
  387. package/templates/next-app/lib/nav.ts.tmpl +1 -1
  388. package/templates/next-app/package.json.tmpl +1 -1
@@ -21,10 +21,14 @@
21
21
  "get_deal_analytics_deals_analytics_get",
22
22
  "get_deal_deals__deal_id__get",
23
23
  "get_deal_stage_velocity_deals_stage_velocity_get",
24
+ "get_my_team_member_team_members_me_get",
24
25
  "get_pipeline_pipelines__pipeline_id__get",
26
+ "list_contacts_contacts_get",
25
27
  "list_deal_history_deals__deal_id__history_get",
26
28
  "list_deals_deals_get",
27
29
  "list_pipelines_deals_pipelines_get",
30
+ "list_quotes_quotes_get",
31
+ "list_team_members_team_members_get",
28
32
  "reorder_stages_pipelines__pipeline_id__stages_reorder_put",
29
33
  "update_deal_deals__deal_id__patch",
30
34
  "update_stage_pipelines__pipeline_id__stages__stage_id__put"
@@ -92,6 +96,30 @@
92
96
  "tiers": [
93
97
  "read"
94
98
  ]
99
+ },
100
+ {
101
+ "name": "DealsScreen",
102
+ "file": "components/deals-screen.tsx",
103
+ "purpose": "Finished deals product: board and list, new deal with the owner defaulted to the API key user, deal detail with activity, forecast and pipeline settings.",
104
+ "operations": [
105
+ "get_deal_deals__deal_id__get",
106
+ "list_contacts_contacts_get"
107
+ ],
108
+ "tiers": [
109
+ "read"
110
+ ]
111
+ },
112
+ {
113
+ "name": "DealContactsTab",
114
+ "file": "components/deal-related.tsx",
115
+ "purpose": "A deal's contacts with roles; open one for the full CRM record. Also exports DealQuotesTab, a read-only list of the deal's quotes.",
116
+ "operations": [
117
+ "get_deal_deals__deal_id__get",
118
+ "list_quotes_quotes_get"
119
+ ],
120
+ "tiers": [
121
+ "read"
122
+ ]
95
123
  }
96
124
  ],
97
125
  "hooks": [
@@ -111,6 +139,15 @@
111
139
  "list_deals_deals_get",
112
140
  "update_deal_deals__deal_id__patch"
113
141
  ]
142
+ },
143
+ {
144
+ "name": "useDefaultOwner",
145
+ "file": "hooks/use-default-owner.ts",
146
+ "purpose": "The default deal owner: the API key user, else the first team member, plus the owner picker options.",
147
+ "operations": [
148
+ "get_my_team_member_team_members_me_get",
149
+ "list_team_members_team_members_get"
150
+ ]
114
151
  }
115
152
  ],
116
153
  "lib": [
@@ -160,9 +197,12 @@
160
197
  "campaigns:delete",
161
198
  "campaigns:read",
162
199
  "campaigns:write",
200
+ "contacts:read",
163
201
  "deals:delete",
164
202
  "deals:read",
165
- "deals:write"
203
+ "deals:write",
204
+ "quotes:read",
205
+ "team:read"
166
206
  ],
167
207
  "defaults": [
168
208
  {
@@ -182,8 +222,8 @@
182
222
  },
183
223
  {
184
224
  "decision": "Who owns deals created in the UI",
185
- "default": "The signed-in user of your app: pass their graph8 user id as ownerId (the API requires an owner)",
186
- "askHuman": true
225
+ "default": "The API key's own user (GET /team-members/me). With an organisation key there is no person, so the first team member is preselected in an owner picker the person can change. Pass ownerId to DealDetail to override",
226
+ "askHuman": false
187
227
  },
188
228
  {
189
229
  "decision": "Weighted forecast",
@@ -193,6 +233,7 @@
193
233
  ],
194
234
  "peerDependencies": {
195
235
  "react": ">=18",
196
- "@dnd-kit/core": "^6.3.1"
236
+ "@dnd-kit/core": "^6.3.1",
237
+ "lucide-react": ">=0.400.0"
197
238
  }
198
239
  }
@@ -12,7 +12,7 @@
12
12
  * 5. DELETE /deals/{id} (deals.deleteDeal) DESTRUCTIVE -> ConfirmSpendDialog first
13
13
  * Compose notes, tasks and the activity timeline from the crm block under `children`.
14
14
  */
15
- import { useEffect, useState, type ReactNode } from "react";
15
+ import { useEffect, useRef, useState, type ReactNode } from "react";
16
16
  import { Button, Input, Label, Skeleton, Textarea } from "@graph8/sdk/ui";
17
17
  import { ApiError } from "@/g8/components/api-error";
18
18
  import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
@@ -21,6 +21,7 @@ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
21
21
  import { useG8Query } from "@/g8/hooks/use-g8-query";
22
22
  import { usePipelines } from "../hooks/use-pipelines";
23
23
  import { dataOf, formatMoney, type Deal } from "../lib/deals-types";
24
+ import { useBrand } from "@/g8/hooks/use-brand";
24
25
 
25
26
  export interface DealDetailProps {
26
27
  /** Edit this deal; omit to create one. */
@@ -30,6 +31,8 @@ export interface DealDetailProps {
30
31
  /** Create mode: contact ids to link (the API requires the list; it may be empty). */
31
32
  contactIds?: number[];
32
33
  pipelineId?: string;
34
+ /** Create mode: allow a second deal for a company that already has one (the API refuses it with 409 otherwise). */
35
+ allowDuplicate?: boolean;
33
36
  onSaved?: (deal: Deal) => void;
34
37
  onDeleted?: (dealId: string) => void;
35
38
  children?: ReactNode;
@@ -50,7 +53,8 @@ function fromDeal(d: Deal): Form {
50
53
  return { name: d.name ?? "", amount: d.amount != null ? String(d.amount) : "", closeDate: d.close_date?.slice(0, 10) ?? "", stageId: d.stage_id ?? "", description: d.description ?? "" };
51
54
  }
52
55
 
53
- export function DealDetail({ dealId, ownerId, contactIds = [], pipelineId, onSaved, onDeleted, children, className }: DealDetailProps) {
56
+ export function DealDetail({ dealId, ownerId, contactIds = [], pipelineId, allowDuplicate, onSaved, onDeleted, children, className }: DealDetailProps) {
57
+ const brand = useBrand();
54
58
  const { pipelines, defaultPipeline } = usePipelines();
55
59
  const [form, setForm] = useState<Form>(EMPTY);
56
60
  const [confirmDelete, setConfirmDelete] = useState(false);
@@ -58,14 +62,23 @@ export function DealDetail({ dealId, ownerId, contactIds = [], pipelineId, onSav
58
62
  const [createdId, setCreatedId] = useState<string | null>(null);
59
63
  const [createdRevision, setCreatedRevision] = useState<number | null>(null);
60
64
  const effectiveId = dealId ?? createdId ?? undefined;
61
- const deal = useG8Query(async (signal) => dataOf<Deal>(await g8Get(`/deals/${encodeURIComponent(dealId as string)}`, undefined, { signal })), [dealId], { enabled: Boolean(dealId) });
62
- const history = useG8Query(async (signal) => dataOf<{ items?: HistoryItem[] }>(await g8Get(`/deals/${encodeURIComponent(dealId as string)}/history`, { limit: 20 }, { signal })).items ?? [], [dealId], { enabled: Boolean(dealId) });
63
- useEffect(() => { if (deal.data) setForm(fromDeal(deal.data)); }, [deal.data]);
64
- // A different deal (or create mode again) must not show the previous one's fields.
65
- useEffect(() => { setForm(EMPTY); setCreatedId(null); setCreatedRevision(null); setConfirmDelete(false); }, [dealId]);
65
+ const deal = useG8Query(async (signal) => dataOf<Deal>(await g8Get(`/deals/${encodeURIComponent(dealId as string)}`, undefined, { signal })), [dealId], {
66
+ enabled: Boolean(dealId),
67
+ key: ["deals", "detail", dealId ?? null],
68
+ // The form is seeded from this record: never swap it under someone typing on a window refocus.
69
+ refetchOnFocus: false,
70
+ });
71
+ const history = useG8Query(async (signal) => dataOf<{ items?: HistoryItem[] }>(await g8Get(`/deals/${encodeURIComponent(dealId as string)}/history`, { limit: 20 }, { signal })).items ?? [], [dealId], { enabled: Boolean(dealId), key: ["deals", "history", dealId ?? null] });
72
+ // Unsaved edits win over a refreshed record (focus, invalidation from a board move, refetch): only an
73
+ // untouched form follows the server. Saving or switching deals makes the form untouched again.
74
+ const dirty = useRef(false);
75
+ // A different deal (or create mode again) must not show the previous one's fields. Declared BEFORE the
76
+ // seeding effect so that, when the record is already cached, the reset runs first and the seed wins.
77
+ useEffect(() => { dirty.current = false; setForm(EMPTY); setCreatedId(null); setCreatedRevision(null); setConfirmDelete(false); }, [dealId]);
78
+ useEffect(() => { if (deal.data && !dirty.current) setForm(fromDeal(deal.data)); }, [deal.data]);
66
79
 
67
80
  const pipeline = pipelines.find((p) => p.id === (deal.data?.pipeline_id ?? pipelineId)) ?? defaultPipeline;
68
- const set = <K extends keyof Form>(k: K, v: Form[K]) => setForm((f) => ({ ...f, [k]: v }));
81
+ const set = <K extends keyof Form>(k: K, v: Form[K]) => { dirty.current = true; setForm((f) => ({ ...f, [k]: v })); };
69
82
 
70
83
  const save = useG8Mutation(async () => {
71
84
  const body = {
@@ -77,13 +90,14 @@ export function DealDetail({ dealId, ownerId, contactIds = [], pipelineId, onSav
77
90
  };
78
91
  const env = effectiveId
79
92
  ? await g8Patch(`/deals/${encodeURIComponent(effectiveId)}`, { ...body, expected_revision: (dealId ? deal.data?.revision : createdRevision) ?? undefined })
80
- : await g8Post("/deals", { ...body, owner_id: ownerId, contact_ids: contactIds, pipeline_id: pipeline?.id, amount: body.amount ?? undefined, close_date: body.close_date ?? undefined });
93
+ : await g8Post("/deals", { ...body, owner_id: ownerId, contact_ids: contactIds, allow_duplicate: allowDuplicate || undefined, pipeline_id: pipeline?.id, amount: body.amount ?? undefined, close_date: body.close_date ?? undefined });
81
94
  return dataOf<Deal>(env);
82
- }, { onSuccess: (saved) => { if (!dealId && saved.id) { setCreatedId(saved.id); setCreatedRevision(saved.revision ?? null); } if (dealId) { void deal.refetch(); void history.refetch(); } onSaved?.(saved); } });
83
- const remove = useG8Mutation(async () => { await g8Delete(`/deals/${encodeURIComponent(dealId as string)}`); }, { onSuccess: () => { setConfirmDelete(false); onDeleted?.(dealId as string); }, onError: () => setConfirmDelete(false) });
95
+ }, { invalidates: [["deals", "list"], ["deals", "history"]], onSuccess: (saved) => { dirty.current = false; if (!dealId && saved.id) { setCreatedId(saved.id); setCreatedRevision(saved.revision ?? null); } if (dealId) { void deal.refetch(); void history.refetch(); } onSaved?.(saved); } });
96
+ const remove = useG8Mutation(async () => { await g8Delete(`/deals/${encodeURIComponent(dealId as string)}`); }, { invalidates: [["deals", "list"]], onSuccess: () => { setConfirmDelete(false); onDeleted?.(dealId as string); }, onError: () => setConfirmDelete(false) });
84
97
 
85
98
  if (deal.error) return <ApiError error={deal.error} title="Could not load the deal" onRetry={() => void deal.refetch()} />;
86
- if (dealId && deal.loading && !deal.data) return <Skeleton className="h-56 w-full" aria-label="Loading deal" />;
99
+ // The hook keeps the previous deal's data while the next loads: wait for THIS deal, so a blank form is never shown (or saved) over it.
100
+ if (dealId && (!deal.data || String(deal.data.id) !== String(dealId))) return <Skeleton className="h-56 w-full" aria-label="Loading deal" />;
87
101
  const d = deal.data;
88
102
  const canCreate = Boolean(ownerId);
89
103
 
@@ -103,7 +117,7 @@ export function DealDetail({ dealId, ownerId, contactIds = [], pipelineId, onSav
103
117
  </div>
104
118
  </div>
105
119
  <div className="space-y-1"><Label htmlFor="deal-desc">Description</Label><Textarea id="deal-desc" rows={3} value={form.description} onChange={(e) => set("description", e.target.value)} /></div>
106
- {!dealId && !canCreate ? <p className="text-sm text-muted-foreground">Pass ownerId (a graph8 user id) to create deals: the API requires an owner.</p> : null}
120
+ {!dealId && !canCreate ? <p className="text-sm text-muted-foreground">Pass ownerId (a {brand.linkLabel} user id) to create deals: the API requires an owner.</p> : null}
107
121
  {save.error ? <ApiError error={save.error} title="Could not save the deal" /> : null}
108
122
  {remove.error ? <ApiError error={remove.error} title="Could not delete the deal" /> : null}
109
123
  <div className="flex justify-between">
@@ -0,0 +1,87 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Related records of one deal, as tabs of the deal's full detail: its contacts (open one for the full CRM record)
5
+ * and its quotes (read-only list; open one in your quotes screen through `onOpenQuote`).
6
+ *
7
+ * SDK operations, in order:
8
+ * DealContactsTab: GET /deals/{id} (deals.getDeal) read - the deal's contacts and roles
9
+ * GET /contacts/{id} ... (crm ContactDetail, in a side sheet)
10
+ * DealQuotesTab: GET /quotes?deal_id=&page=&limit (quotes.listQuotes) read - needs the quotes:read scope
11
+ * Nothing here is billable, external or destructive.
12
+ */
13
+ import { useState } from "react";
14
+ import { FileText, Users } from "lucide-react";
15
+ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
16
+ import { ApiError } from "@/g8/components/api-error";
17
+ import { EmptyState } from "@/g8/components/empty-state";
18
+ import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
19
+ import { StatusPill, toneForStatus, humanizeStatus } from "@/g8/components/screen/status-pill";
20
+ import { g8Get } from "@/g8/lib/g8-client";
21
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
22
+ import { ContactDetail } from "@/g8/crm/components/contact-detail";
23
+ import { dataOf, type Deal } from "../lib/deals-types";
24
+
25
+ export function DealContactsTab({ dealId }: { dealId: string }) {
26
+ const [open, setOpen] = useState<number | null>(null);
27
+ const q = useG8Query(async (signal) => dataOf<Deal>(await g8Get(`/deals/${encodeURIComponent(dealId)}`, undefined, { signal })), [dealId], { key: ["deals", "detail", dealId] });
28
+ if (q.error) return <ApiError error={q.error} title="Could not load the deal's contacts" onRetry={() => void q.refetch()} />;
29
+ if (!q.data) return <Skeleton className="h-24 w-full" aria-label="Loading contacts" />;
30
+ const contacts = q.data.contacts ?? [];
31
+ if (contacts.length === 0) return <EmptyState icon={<Users className="size-8" />} title="No contacts on this deal" description="Contacts are attached when the deal is created." />;
32
+ return (
33
+ <>
34
+ <ul className="divide-y rounded-md border" aria-label="Deal contacts">
35
+ {contacts.map((c) => (
36
+ <li key={c.id}>
37
+ <button type="button" className="flex w-full items-center gap-3 p-3 text-left hover:bg-muted" onClick={() => setOpen(c.id)}>
38
+ <EntityAvatar name={c.name || c.email || "Contact"} size="md" />
39
+ <span className="min-w-0 flex-1">
40
+ <span className="block truncate text-sm font-medium">{c.name || c.email || `Contact ${c.id}`}</span>
41
+ <span className="block truncate text-xs text-muted-foreground">{[c.title, c.email].filter(Boolean).join(" · ")}</span>
42
+ </span>
43
+ {c.role ? <StatusPill tone="neutral">{humanizeStatus(c.role)}</StatusPill> : null}
44
+ {q.data?.primary_contact?.id === c.id ? <StatusPill tone="info">Primary</StatusPill> : null}
45
+ </button>
46
+ </li>
47
+ ))}
48
+ </ul>
49
+ <Sheet open={open != null} onOpenChange={(o) => { if (!o) setOpen(null); }}>
50
+ <SheetContent className="w-full overflow-y-auto sm:max-w-2xl">
51
+ <SheetHeader><SheetTitle>Contact</SheetTitle><SheetDescription>The full CRM record.</SheetDescription></SheetHeader>
52
+ {open != null ? <ContactDetail contactId={open} /> : null}
53
+ </SheetContent>
54
+ </Sheet>
55
+ </>
56
+ );
57
+ }
58
+
59
+ interface QuoteRow { id: string; title?: string | null; quote_number?: string | null; status?: string | null; total?: number | null; currency?: string | null }
60
+
61
+ export function DealQuotesTab({ dealId, onOpenQuote }: { dealId: string; onOpenQuote?: (quoteId: string) => void }) {
62
+ const q = useG8Query(async (signal) => {
63
+ const env = await g8Get("/quotes", { deal_id: dealId, page: 1, limit: 50 }, { signal });
64
+ const inner = dataOf<{ items?: QuoteRow[] } | QuoteRow[]>(env);
65
+ return Array.isArray(inner) ? inner : (inner.items ?? []);
66
+ }, [dealId]);
67
+ if (q.error) return <ApiError error={q.error} title="Could not load quotes" onRetry={() => void q.refetch()} />;
68
+ if (!q.data) return <Skeleton className="h-24 w-full" aria-label="Loading quotes" />;
69
+ if (q.data.length === 0) return <EmptyState icon={<FileText className="size-8" />} title="No quotes for this deal" description="Create one from the Quotes screen." />;
70
+ return (
71
+ <Table>
72
+ <TableHeader><TableRow><TableHead>Quote</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Total</TableHead></TableRow></TableHeader>
73
+ <TableBody>
74
+ {q.data.map((r) => (
75
+ <TableRow key={r.id}>
76
+ <TableCell>
77
+ {onOpenQuote ? <button type="button" className="font-medium hover:underline" onClick={() => onOpenQuote(r.id)}>{r.title || "Untitled quote"}</button> : <span className="font-medium">{r.title || "Untitled quote"}</span>}
78
+ <div className="text-xs text-muted-foreground">{r.quote_number}</div>
79
+ </TableCell>
80
+ <TableCell><StatusPill tone={toneForStatus(r.status)} dot>{humanizeStatus(r.status)}</StatusPill></TableCell>
81
+ <TableCell className="text-right tabular-nums">{r.total == null ? "" : (r.total / 100).toLocaleString(undefined, { style: "currency", currency: r.currency || "USD" })}</TableCell>
82
+ </TableRow>
83
+ ))}
84
+ </TableBody>
85
+ </Table>
86
+ );
87
+ }
@@ -0,0 +1,298 @@
1
+ "use client";
2
+
3
+ /**
4
+ * DealsScreen: the finished deals product. Board and list of the pipeline, a deal detail, new deal, forecast and
5
+ * pipeline stage settings in one screen. No props are required.
6
+ *
7
+ * It composes: PipelineBoard (kanban, drag or "Move to"), a deals table, DealDetail (edit, stage history, delete),
8
+ * ForecastView, StageEditor, DealContactsTab, DealQuotesTab and the crm block's ActivityTimeline and ContactDetail.
9
+ *
10
+ * Flows:
11
+ * - Board / List toggle, search by deal name, switch pipeline (when there is more than one).
12
+ * - Headline numbers: open deals, open value, estimated weighted value, won value (from the loaded deals).
13
+ * - Open a deal (board card or list row; deep link ?id=, full page with &view=full) -> Details (every field,
14
+ * edit, stage history, confirmed delete), Contacts (open the full CRM record), Quotes and Activity tabs. Moving a card into a won/lost stage asks for the close date and amount.
15
+ * - New deal: name, amount, close date, stage, plus the two things the API requires. The OWNER defaults to the API
16
+ * key's own user (GET /team-members/me); with an organisation key it defaults to the first team member and an
17
+ * owner picker lets the person change it. At least ONE CONTACT is required by the API (`contact_ids`), found with a
18
+ * search box. A company that already has a deal is refused (409) unless "Allow another deal" is ticked.
19
+ * - Forecast tab (ForecastView) and Pipeline settings tab (stage summary with deal counts and values, plus StageEditor: names, probabilities, order, delete).
20
+ *
21
+ * SDK operations (all through the composed parts): listPipelines, listDeals, getDeal, listDealHistory, createDeal,
22
+ * updateDeal, deleteDeal (confirmed), getDealAnalytics, getDealStageVelocity, the pipeline-stage writes,
23
+ * listTeamMembers + getMyTeamMember (the owner default) and the crm block's listContacts + activity feed.
24
+ * Nothing here is billable or external; delete is destructive and goes through ConfirmSpendDialog inside DealDetail.
25
+ *
26
+ * Slots for cross-block glue (all optional): renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions.
27
+ */
28
+ import { useMemo, useState, type ReactNode } from "react";
29
+ import { Handshake, Plus, Search, X } from "lucide-react";
30
+ import { Button, Input, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
31
+ import { ApiError } from "@/g8/components/api-error";
32
+ import { EmptyState } from "@/g8/components/empty-state";
33
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
34
+ import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
35
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
36
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
37
+ import { SplitView } from "@/g8/components/screen/split-view";
38
+ import { StatusPill } from "@/g8/components/screen/status-pill";
39
+ import { Toolbar } from "@/g8/components/screen/toolbar";
40
+ import { useUrlState } from "@/g8/hooks/use-url-state";
41
+ import { g8Get } from "@/g8/lib/g8-client";
42
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
43
+ import { ActivityTimeline } from "@/g8/crm/components/activity-timeline";
44
+ import { useDebouncedValue } from "@/g8/crm/hooks/use-debounced";
45
+ import { contactName, type ContactRecord } from "@/g8/crm/lib/crm-types";
46
+ import { rowActivationProps } from "@/g8/hooks/use-row-activation";
47
+ import { RecordLink } from "@/g8/components/screen/record-link";
48
+ import { useDefaultOwner } from "../hooks/use-default-owner";
49
+ import { usePipelineDeals } from "../hooks/use-pipeline-deals";
50
+ import { usePipelines } from "../hooks/use-pipelines";
51
+ import { dataOf, formatDate, formatMoney, stageKind, sumMoney, weightedPipeline, type Deal, type Stage } from "../lib/deals-types";
52
+ import { DealDetail } from "./deal-detail";
53
+ import { DealContactsTab, DealQuotesTab } from "./deal-related";
54
+ import { ForecastView } from "./forecast-view";
55
+ import { PipelineBoard } from "./pipeline-board";
56
+ import { StageEditor } from "./stage-editor";
57
+ import { useBrand } from "@/g8/hooks/use-brand";
58
+
59
+ export interface DealsScreenProps {
60
+ /** Start on this pipeline; defaults to the org's default one. */
61
+ pipelineId?: string;
62
+ /** Extra buttons per list row (for example "Create quote" from the quotes block). */
63
+ renderRowActions?: (deal: Deal) => ReactNode;
64
+ /** Extra buttons in the detail header. */
65
+ renderDetailActions?: (deal: Deal) => ReactNode;
66
+ /** Extra tabs in the detail panel, for example a Quotes tab. */
67
+ extraDetailTabs?: Array<{ id: string; label: string; render: (deal: Deal) => ReactNode }>;
68
+ /** Extra controls at the right of the toolbar. */
69
+ toolbarActions?: ReactNode;
70
+ /** Open a quote of the deal in your quotes screen; without it the Quotes tab is read-only. */
71
+ onOpenQuote?: (quoteId: string) => void;
72
+ }
73
+
74
+ type View = "board" | "list" | "forecast" | "stages";
75
+ const VIEWS: Array<{ id: View; label: string }> = [
76
+ { id: "board", label: "Board" },
77
+ { id: "list", label: "List" },
78
+ { id: "forecast", label: "Forecast" },
79
+ { id: "stages", label: "Pipeline settings" },
80
+ ];
81
+ const asView = (v: string | null | undefined): View => (VIEWS.some((x) => x.id === v) ? (v as View) : "board");
82
+
83
+ function stageTone(stage: Stage | undefined): "neutral" | "positive" | "negative" | "info" {
84
+ const kind = stageKind(stage);
85
+ return kind === "won" ? "positive" : kind === "lost" ? "negative" : "info";
86
+ }
87
+
88
+ /** Contact search + chips for the new-deal sheet. The API requires at least one contact. */
89
+ function ContactPicker({ value, onChange }: { value: ContactRecord[]; onChange: (next: ContactRecord[]) => void }) {
90
+ const brand = useBrand();
91
+ const [text, setText] = useState("");
92
+ const term = useDebouncedValue(text.trim());
93
+ const found = useG8Query(async (signal) => {
94
+ const env = await g8Get<ContactRecord[]>("/contacts", { name: term, limit: 8, page: 1 }, { signal });
95
+ return env.data ?? [];
96
+ }, [term], { enabled: term.length > 1 });
97
+ const choices = (found.data ?? []).filter((c) => !value.some((v) => v.id === c.id));
98
+ return (
99
+ <div className="space-y-2">
100
+ <label htmlFor="deal-contact-search" className="text-sm font-medium">Contacts</label>
101
+ <div className="flex flex-wrap gap-1">
102
+ {value.map((c) => (
103
+ <span key={c.id} className="inline-flex items-center gap-1 rounded-full border bg-muted px-2 py-0.5 text-xs">
104
+ {contactName(c)}
105
+ <button type="button" aria-label={`Remove ${contactName(c)}`} onClick={() => onChange(value.filter((v) => v.id !== c.id))}><X className="size-3" /></button>
106
+ </span>
107
+ ))}
108
+ </div>
109
+ <div className="relative">
110
+ <Search className="pointer-events-none absolute left-2 top-2.5 size-4 text-muted-foreground" aria-hidden />
111
+ <Input id="deal-contact-search" className="pl-8" placeholder="Search contacts by name" value={text} onChange={(e) => setText(e.target.value)} />
112
+ </div>
113
+ {found.error ? <ApiError error={found.error} title="Could not search contacts" /> : null}
114
+ {term.length > 1 && !found.loading && choices.length === 0 && !found.error ? <p className="text-xs text-muted-foreground">No other contacts match.</p> : null}
115
+ <ul className="divide-y rounded-md border empty:hidden" aria-label="Matching contacts">
116
+ {choices.map((c) => (
117
+ <li key={c.id}>
118
+ <button type="button" className="flex w-full items-center gap-2 p-2 text-left text-sm hover:bg-muted" onClick={() => { onChange([...value, c]); setText(""); }}>
119
+ <EntityAvatar name={contactName(c)} size="sm" />
120
+ <span className="font-medium">{contactName(c)}</span>
121
+ <span className="text-xs text-muted-foreground">{[c.job_title, c.company_name].filter(Boolean).join(" at ")}</span>
122
+ </button>
123
+ </li>
124
+ ))}
125
+ </ul>
126
+ <p className="text-xs text-muted-foreground">{brand.linkLabel} needs at least one contact on a deal. Every contact must work at the same company.</p>
127
+ </div>
128
+ );
129
+ }
130
+
131
+ function NewDealForm({ pipelineId, onCreated }: { pipelineId?: string; onCreated: (deal: Deal) => void }) {
132
+ const owner = useDefaultOwner();
133
+ const [ownerId, setOwnerId] = useState<string | null>(null);
134
+ const [contacts, setContacts] = useState<ContactRecord[]>([]);
135
+ const [duplicate, setDuplicate] = useState(false);
136
+ const effectiveOwner = ownerId ?? owner.defaultOwner?.id ?? "";
137
+ if (owner.loading) return <Skeleton className="h-40 w-full" aria-label="Loading owners" />;
138
+ if (!effectiveOwner) return <ApiError error={owner.error ?? new Error("No team member found to own this deal.")} title="A deal needs an owner" onRetry={() => void owner.reload()} />;
139
+ return (
140
+ <div className="space-y-4">
141
+ <div className="space-y-1">
142
+ <label htmlFor="deal-owner" className="text-sm font-medium">Owner</label>
143
+ <select id="deal-owner" className="h-9 w-full rounded-md border bg-background px-2 text-sm" value={effectiveOwner} onChange={(e) => setOwnerId(e.target.value)}>
144
+ {owner.owners.map((o) => <option key={o.id} value={o.id}>{o.name}{o.email && o.email !== o.name ? ` (${o.email})` : ""}</option>)}
145
+ </select>
146
+ <p className="text-xs text-muted-foreground">{owner.source === "key" && ownerId === null ? "Defaults to you, the owner of this API key." : "Defaults to the first team member because this key has no person."}</p>
147
+ </div>
148
+ <ContactPicker value={contacts} onChange={setContacts} />
149
+ <label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={duplicate} onChange={(e) => setDuplicate(e.target.checked)} />Allow another deal for a company that already has one</label>
150
+ {contacts.length === 0 ? <p className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">Pick a contact to continue.</p> : (
151
+ <DealDetail key={contacts.map((c) => c.id).join(",")} ownerId={effectiveOwner} contactIds={contacts.map((c) => c.id)} pipelineId={pipelineId} allowDuplicate={duplicate} onSaved={onCreated} />
152
+ )}
153
+ </div>
154
+ );
155
+ }
156
+
157
+ /** Every stage of the pipeline with its type, probability, deal count and value: the read side of pipeline settings. */
158
+ function StageSummary({ stages, deals }: { stages: Stage[]; deals: Deal[] }) {
159
+ return (
160
+ <Table aria-label="Stages">
161
+ <TableHeader><TableRow><TableHead>Stage</TableHead><TableHead>Type</TableHead><TableHead className="text-right">Probability</TableHead><TableHead className="text-right">Deals</TableHead><TableHead className="text-right">Value</TableHead></TableRow></TableHeader>
162
+ <TableBody>
163
+ {stages.map((s) => {
164
+ const here = deals.filter((d) => d.stage_id === s.id);
165
+ return (
166
+ <TableRow key={s.id}>
167
+ <TableCell className="font-medium">{s.name}</TableCell>
168
+ <TableCell><StatusPill tone={stageTone(s)} dot>{stageKind(s) === "won" ? "Closed won" : stageKind(s) === "lost" ? "Closed lost" : "Open"}</StatusPill></TableCell>
169
+ <TableCell className="text-right tabular-nums">{s.probability == null ? "" : `${s.probability}%`}</TableCell>
170
+ <TableCell className="text-right tabular-nums">{here.length.toLocaleString()}</TableCell>
171
+ <TableCell className="text-right tabular-nums">{sumMoney(here)}</TableCell>
172
+ </TableRow>
173
+ );
174
+ })}
175
+ </TableBody>
176
+ </Table>
177
+ );
178
+ }
179
+
180
+ function DealRows({ deals, stages, selectedId, onOpen, renderRowActions }: { deals: Deal[]; stages: Stage[]; selectedId: string | null; onOpen: (d: Deal) => void; renderRowActions?: (d: Deal) => ReactNode }) {
181
+ const byId = new Map(stages.map((s) => [s.id, s]));
182
+ return (
183
+ <Table>
184
+ <TableHeader><TableRow><TableHead>Deal</TableHead><TableHead>Stage</TableHead><TableHead className="text-right">Amount</TableHead><TableHead>Owner</TableHead><TableHead>Close date</TableHead>{renderRowActions ? <TableHead /> : null}</TableRow></TableHeader>
185
+ <TableBody>
186
+ {deals.map((d) => {
187
+ const stage = d.stage_id ? byId.get(d.stage_id) : undefined;
188
+ return (
189
+ <TableRow key={d.id} data-state={selectedId === d.id ? "selected" : undefined} className="cursor-pointer" {...rowActivationProps(() => onOpen(d))}>
190
+ <TableCell><RecordLink param="id" id={d.id} onOpen={() => onOpen(d)}>{d.name || "Untitled deal"}</RecordLink></TableCell>
191
+ <TableCell><StatusPill tone={stageTone(stage)} dot>{stage?.name ?? d.stage_name ?? "Unknown"}</StatusPill></TableCell>
192
+ <TableCell className="text-right tabular-nums">{formatMoney(d.amount, d.currency)}</TableCell>
193
+ <TableCell><span className="inline-flex items-center gap-2"><EntityAvatar name={d.owner_name ?? d.owner_email ?? "?"} size="sm" />{d.owner_name ?? d.owner_email ?? "No owner"}</span></TableCell>
194
+ <TableCell className="tabular-nums">{formatDate(d.close_date)}</TableCell>
195
+ {renderRowActions ? <TableCell onClick={(e) => e.stopPropagation()}>{renderRowActions(d)}</TableCell> : null}
196
+ </TableRow>
197
+ );
198
+ })}
199
+ </TableBody>
200
+ </Table>
201
+ );
202
+ }
203
+
204
+ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, onOpenQuote }: DealsScreenProps) {
205
+ const brand = useBrand();
206
+ const [viewParam, setViewParam] = useUrlState("tab", "board");
207
+ const [selectedId, setSelectedId] = useUrlState("id");
208
+ const [mode, setMode] = useUrlState("view");
209
+ const [pipelineParam, setPipelineParam] = useUrlState("pipeline", initialPipelineId ?? "");
210
+ const [searchText, setSearchText] = useState("");
211
+ const search = useDebouncedValue(searchText.trim());
212
+ const [creating, setCreating] = useState(false);
213
+ const [version, setVersion] = useState(0);
214
+ const view = asView(viewParam);
215
+ const pipelines = usePipelines();
216
+ const pipeline = pipelines.pipelines.find((p) => p.id === pipelineParam) ?? pipelines.defaultPipeline;
217
+ const data = usePipelineDeals(pipeline?.id, { search });
218
+ const stages = pipeline?.stages ?? [];
219
+ const refresh = () => { setVersion((v) => v + 1); void data.reload(); };
220
+
221
+ const stats = useMemo(() => {
222
+ const open = data.deals.filter((d) => stageKind(stages.find((s) => s.id === d.stage_id)) === "open");
223
+ const won = data.deals.filter((d) => stageKind(stages.find((s) => s.id === d.stage_id)) === "won");
224
+ const cur = data.deals[0]?.currency;
225
+ const weighted = (group: ReadonlyArray<Deal>) => Math.round(weightedPipeline(group, stages).value);
226
+ return [
227
+ { label: "Open deals", value: open.length.toLocaleString() },
228
+ { label: "Open value", value: sumMoney(open, undefined, cur) },
229
+ { label: "Weighted (estimate)", value: sumMoney(data.deals, weighted, cur), hint: "Open deals times stage probability" },
230
+ { label: "Won", value: sumMoney(won, undefined, cur), tone: "positive" as const },
231
+ ];
232
+ }, [data.deals, stages]);
233
+
234
+ const listed = data.deals.find((d) => d.id === selectedId);
235
+ // A deep link can point at a deal in another pipeline or hidden by the search: resolve it directly.
236
+ const fetched = useG8Query(async (signal) => dataOf<Deal>(await g8Get(`/deals/${encodeURIComponent(selectedId ?? "")}`, undefined, { signal })), [selectedId, version], { enabled: Boolean(selectedId) && !listed && !data.loading, key: ["deals", "detail", selectedId ?? null] });
237
+ const selected = listed ?? (fetched.data?.id === selectedId ? fetched.data : undefined);
238
+ const open = (id: string) => setSelectedId(id);
239
+ const close = () => { setSelectedId(null); setMode(null); };
240
+ const showToolbar = view === "board" || view === "list";
241
+
242
+ const detail = selectedId ? (
243
+ <DetailPanel
244
+ title={selected?.name || "Deal"}
245
+ subtitle={selected ? [selected.stage_name, formatMoney(selected.amount, selected.currency)].join(" · ") : undefined}
246
+ badges={selected ? <StatusPill tone={stageTone(stages.find((s) => s.id === selected.stage_id))} dot>{selected.stage_name ?? "Open"}</StatusPill> : undefined}
247
+ actions={selected && renderDetailActions ? renderDetailActions(selected) : undefined}
248
+ onClose={close}
249
+ onExpand={mode === "full" ? undefined : () => setMode("full")}
250
+ onCollapse={mode === "full" ? () => setMode(null) : undefined}
251
+ tabs={[
252
+ { id: "details", label: "Details", content: <DealDetail key={selectedId} dealId={selectedId} onSaved={refresh} onDeleted={() => { close(); refresh(); }} /> },
253
+ { id: "contacts", label: "Contacts", content: <DealContactsTab key={selectedId} dealId={selectedId} /> },
254
+ { id: "quotes", label: "Quotes", content: <DealQuotesTab key={selectedId} dealId={selectedId} onOpenQuote={onOpenQuote} /> },
255
+ { id: "activity", label: "Activity", content: <ActivityTimeline entityType="deal" entityId={selectedId} /> },
256
+ ...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: selected ? t.render(selected) : null })),
257
+ ]}
258
+ />
259
+ ) : null;
260
+
261
+ const list = data.error ? <ApiError error={data.error} title="Could not load deals" onRetry={() => void data.reload()} /> : data.loading && data.deals.length === 0 ? <Skeleton className="h-48 w-full" aria-label="Loading deals" /> : data.deals.length === 0 ? (
262
+ <EmptyState icon={<Handshake className="size-8" />} title={search ? "No deals match your search" : "No deals yet"} description={search ? "Try a different name." : "Create your first deal. It lands in the first stage of the pipeline."} action={search ? undefined : <Button onClick={() => setCreating(true)}>New deal</Button>} />
263
+ ) : <DealRows deals={data.deals} stages={stages} selectedId={selectedId} onOpen={(d) => open(d.id)} renderRowActions={renderRowActions} />;
264
+
265
+ let body: ReactNode;
266
+ if (view === "forecast") body = <ForecastView pipelineId={pipeline?.id} />;
267
+ else if (view === "stages") body = pipeline ? <div className="space-y-6"><StageSummary stages={stages} deals={data.deals} /><StageEditor pipelineId={pipeline.id} onChanged={refresh} /></div> : <EmptyState title="No pipeline yet" description={`Create a pipeline in ${brand.linkLabel} first.`} />;
268
+ else body = <SplitView detailOpen={Boolean(selectedId)} onCloseDetail={close} expanded={mode === "full" && Boolean(selectedId)} onExpandedChange={(full) => setMode(full ? "full" : null)} detailWidth="lg" detail={detail} list={view === "board" ? <PipelineBoard key={`${pipeline?.id}-${version}`} pipelineId={pipeline?.id} search={search} onSelectDeal={(d) => open(d.id)} onChanged={() => void data.reload()} /> : list} />;
269
+
270
+ return (
271
+ <ScreenLayout
272
+ title="Deals"
273
+ description="Move deals through your pipeline and see what is likely to close."
274
+ icon={<Handshake className="size-5" />}
275
+ actions={<Button onClick={() => setCreating(true)}><Plus className="size-4" aria-hidden />New deal</Button>}
276
+ tabs={VIEWS}
277
+ activeTab={view}
278
+ onTabChange={(id) => { setViewParam(id); if (id !== "board" && id !== "list") setSearchText(""); }}
279
+ stats={stats}
280
+ toolbar={showToolbar ? (
281
+ <Toolbar
282
+ search={{ value: searchText, onChange: setSearchText, placeholder: "Search deals" }}
283
+ filters={pipelines.pipelines.length > 1 ? (
284
+ <select aria-label="Pipeline" className="h-9 rounded-md border bg-background px-2 text-sm" value={pipeline?.id ?? ""} onChange={(e) => { setPipelineParam(e.target.value); close(); }}>
285
+ {pipelines.pipelines.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
286
+ </select>
287
+ ) : undefined}
288
+ actions={toolbarActions}
289
+ />
290
+ ) : undefined}
291
+ >
292
+ {pipelines.error ? <ApiError error={pipelines.error} title="Could not load pipelines" onRetry={() => void pipelines.refetch()} /> : body}
293
+ <FormSheet open={creating} onOpenChange={setCreating} title="New deal" description="The deal lands in the first stage unless you pick another.">
294
+ {creating ? <NewDealForm pipelineId={pipeline?.id} onCreated={(deal) => { setCreating(false); refresh(); if (deal.id) { setViewParam("board"); open(deal.id); } }} /> : null}
295
+ </FormSheet>
296
+ </ScreenLayout>
297
+ );
298
+ }
@@ -29,6 +29,8 @@ export interface PipelineBoardProps {
29
29
  search?: string;
30
30
  ownerId?: string;
31
31
  onSelectDeal?: (deal: Deal) => void;
32
+ /** Called after a stage move was saved, so a screen can refresh its totals. */
33
+ onChanged?: () => void;
32
34
  className?: string;
33
35
  }
34
36
 
@@ -92,7 +94,7 @@ function CloseDialog({ stage, deal, onConfirm, onCancel }: { stage: Stage; deal:
92
94
  );
93
95
  }
94
96
 
95
- export function PipelineBoard({ pipelineId, search, ownerId, onSelectDeal, className }: PipelineBoardProps) {
97
+ export function PipelineBoard({ pipelineId, search, ownerId, onSelectDeal, onChanged, className }: PipelineBoardProps) {
96
98
  const pipelines = usePipelines();
97
99
  const pipeline = pipelines.pipelines.find((p) => p.id === pipelineId) ?? pipelines.defaultPipeline;
98
100
  const { deals, loading, error, reload, moveDeal, movingIds } = usePipelineDeals(pipeline?.id, { search, ownerId });
@@ -110,7 +112,7 @@ export function PipelineBoard({ pipelineId, search, ownerId, onSelectDeal, class
110
112
  if (deal.stage_id === stage.id || movingIds.has(deal.id)) return;
111
113
  setMoveError(null);
112
114
  if (stageKind(stage) !== "open" && !extra) { setClosing({ deal, stage }); return; }
113
- try { await moveDeal(deal, stage.id, extra); } catch (err) {
115
+ try { await moveDeal(deal, stage.id, extra); onChanged?.(); } catch (err) {
114
116
  const marker = closedStageMarker(err);
115
117
  setMoveError(marker ? new Error(`The API needs more before this deal can close (${marker.replace(/_/g, " ")}).`) : err);
116
118
  }
@@ -45,21 +45,21 @@ export function StageEditor({ pipelineId, onChanged, className }: StageEditorPro
45
45
  const saved = dataOf<Stage>(await g8Put(`${base}/stages/${encodeURIComponent(s.id)}`, { name: s.name, probability: s.probability ?? 0, stage_type: s.stage_type ?? "open", color: s.color ?? undefined }));
46
46
  done(stages.map((x) => (x.id === saved.id ? { ...x, ...saved } : x)));
47
47
  } finally { setSavingId(null); }
48
- });
48
+ }, { invalidates: [["deals", "pipelines"]] });
49
49
  const add = useG8Mutation(async (name: string) => {
50
50
  const created = dataOf<Stage>(await g8Post(`${base}/stages`, { name, probability: 0, stage_type: "open" }));
51
51
  done([...stages, created]);
52
52
  setNewName("");
53
- });
53
+ }, { invalidates: [["deals", "pipelines"]] });
54
54
  const reorder = useG8Mutation(async (next: Stage[]) => {
55
55
  await g8Put(`${base}/stages/reorder`, { stage_ids: next.map((s) => s.id) });
56
56
  done(next);
57
- });
57
+ }, { invalidates: [["deals", "pipelines"]] });
58
58
  const remove = useG8Mutation(async (s: Stage) => {
59
59
  await g8Delete(`${base}/stages/${encodeURIComponent(s.id)}`);
60
60
  done(stages.filter((x) => x.id !== s.id));
61
61
  setRemoving(null);
62
- }, { onError: () => setRemoving(null) });
62
+ }, { invalidates: [["deals", "pipelines"]], onError: () => setRemoving(null) });
63
63
 
64
64
  const patch = (id: string, change: Partial<Stage>) => setStages((all) => all.map((s) => (s.id === id ? { ...s, ...change } : s)));
65
65
  const swap = (i: number, j: number) => { const next = [...stages]; [next[i], next[j]] = [next[j], next[i]]; void reorder.mutate(next); };