@graph8/sdk 0.370.0 → 0.373.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 (466) hide show
  1. package/README.md +19 -0
  2. package/blocks/_foundation/README.md +14 -0
  3. package/blocks/_foundation/block.json +44 -0
  4. package/blocks/_foundation/components/confirm-spend-dialog.tsx +15 -8
  5. package/blocks/_foundation/components/empty-state.tsx +5 -2
  6. package/blocks/_foundation/components/list-select.tsx +112 -0
  7. package/blocks/_foundation/components/paging-notices.tsx +55 -0
  8. package/blocks/_foundation/components/screen/detail-panel.tsx +2 -1
  9. package/blocks/_foundation/components/screen/filter-controls.tsx +92 -0
  10. package/blocks/_foundation/components/screen/index.ts +1 -0
  11. package/blocks/_foundation/components/screen/split-view.tsx +1 -1
  12. package/blocks/_foundation/hooks/use-all-pages.ts +54 -0
  13. package/blocks/_foundation/hooks/use-load-more-list.ts +61 -0
  14. package/blocks/_foundation/hooks/use-url-filters.ts +93 -0
  15. package/blocks/_foundation/lib/paging.ts +57 -0
  16. package/blocks/_foundation/lib/use-debounced-value.ts +13 -0
  17. package/blocks/agents/README.md +81 -4
  18. package/blocks/agents/block.json +311 -2
  19. package/blocks/agents/components/agent-activity.tsx +112 -0
  20. package/blocks/agents/components/agent-approvals.tsx +135 -0
  21. package/blocks/agents/components/agent-builder.tsx +17 -3
  22. package/blocks/agents/components/agent-conversations.tsx +143 -0
  23. package/blocks/agents/components/agent-knowledge-panel.tsx +2 -2
  24. package/blocks/agents/components/agent-operators.tsx +164 -0
  25. package/blocks/agents/components/agent-run-detail.tsx +20 -3
  26. package/blocks/agents/components/agent-runs.tsx +3 -3
  27. package/blocks/agents/components/agent-skills-panel.tsx +7 -7
  28. package/blocks/agents/components/agent-test-history.tsx +121 -0
  29. package/blocks/agents/components/agents-screen.tsx +53 -11
  30. package/blocks/agents/components/assistant-sessions.tsx +150 -0
  31. package/blocks/agents/components/duplicate-agent-button.tsx +42 -0
  32. package/blocks/agents/components/from-template-button.tsx +65 -0
  33. package/blocks/agents/components/knowledge-browser.tsx +107 -0
  34. package/blocks/agents/components/knowledge-detail.tsx +156 -0
  35. package/blocks/agents/components/knowledge-form.tsx +69 -0
  36. package/blocks/agents/components/name-dialog.tsx +32 -0
  37. package/blocks/agents/components/receptionist-button.tsx +46 -0
  38. package/blocks/agents/components/save-as-template-button.tsx +32 -0
  39. package/blocks/agents/components/skill-editor.tsx +147 -0
  40. package/blocks/agents/components/skill-library.tsx +111 -0
  41. package/blocks/agents/lib/agent-types.ts +7 -1
  42. package/blocks/agents/lib/facts.tsx +13 -0
  43. package/blocks/agents/lib/knowledge-utils.ts +49 -0
  44. package/blocks/agents/lib/load-more.tsx +141 -0
  45. package/blocks/agents/lib/skill-utils.ts +91 -0
  46. package/blocks/agents/recipes/create-agent-and-attach-skill.ts +1 -1
  47. package/blocks/analytics/components/analytics-screen.tsx +16 -3
  48. package/blocks/analytics/components/kpi-tiles.tsx +1 -1
  49. package/blocks/analytics/components/report-builder.tsx +15 -2
  50. package/blocks/appointments/README.md +6 -0
  51. package/blocks/appointments/block.json +27 -0
  52. package/blocks/appointments/components/booking-page.tsx +2 -2
  53. package/blocks/appointments/components/meeting-contact-links.tsx +49 -0
  54. package/blocks/appointments/components/meeting-detail.tsx +2 -2
  55. package/blocks/appointments/components/meeting-list.tsx +29 -13
  56. package/blocks/appointments/components/record-meetings-tab.tsx +92 -0
  57. package/blocks/campaigns/components/campaign-assets.tsx +1 -1
  58. package/blocks/campaigns/components/campaign-launch-step.tsx +16 -11
  59. package/blocks/campaigns/components/campaign-metrics.tsx +1 -1
  60. package/blocks/campaigns/components/campaign-wizard.tsx +10 -5
  61. package/blocks/campaigns/components/icp-detail.tsx +11 -2
  62. package/blocks/campaigns/components/icp-editor.tsx +8 -3
  63. package/blocks/campaigns/components/persona-detail.tsx +11 -2
  64. package/blocks/campaigns/components/persona-editor.tsx +6 -1
  65. package/blocks/campaigns/lib/campaigns-utils.ts +4 -4
  66. package/blocks/coach/components/call-scorecard.tsx +7 -7
  67. package/blocks/company-intelligence/README.md +132 -0
  68. package/blocks/company-intelligence/block.json +318 -0
  69. package/blocks/company-intelligence/components/company-intelligence-screen.tsx +114 -0
  70. package/blocks/company-intelligence/components/company-intelligence-tab.tsx +88 -0
  71. package/blocks/company-intelligence/components/company-profile.tsx +68 -0
  72. package/blocks/company-intelligence/components/intelligence-sources.tsx +63 -0
  73. package/blocks/company-intelligence/components/report-detail.tsx +189 -0
  74. package/blocks/company-intelligence/components/research-reports.tsx +136 -0
  75. package/blocks/company-intelligence/components/research-run.tsx +161 -0
  76. package/blocks/company-intelligence/components/source-detail.tsx +117 -0
  77. package/blocks/company-intelligence/components/source-view.tsx +274 -0
  78. package/blocks/company-intelligence/components/wiki-document-detail.tsx +116 -0
  79. package/blocks/company-intelligence/components/wiki-documents.tsx +66 -0
  80. package/blocks/company-intelligence/hooks/use-intelligence.ts +173 -0
  81. package/blocks/company-intelligence/lib/intelligence-types.ts +230 -0
  82. package/blocks/company-intelligence/lib/safe-markdown.tsx +150 -0
  83. package/blocks/company-intelligence/recipes/read-competitors-and-news.ts +33 -0
  84. package/blocks/company-intelligence/recipes/research-your-website.ts +43 -0
  85. package/blocks/company-intelligence/recipes/write-the-research-reports.ts +54 -0
  86. package/blocks/copilot/components/copilot-memory.tsx +6 -2
  87. package/blocks/copilot/components/copilot-panel.tsx +1 -1
  88. package/blocks/copilot/components/copilot-team-chat.tsx +6 -1
  89. package/blocks/crm/README.md +6 -0
  90. package/blocks/crm/block.json +53 -4
  91. package/blocks/crm/components/companies-screen.tsx +23 -9
  92. package/blocks/crm/components/company-detail.tsx +2 -2
  93. package/blocks/crm/components/contact-detail.tsx +4 -4
  94. package/blocks/crm/components/contact-lookup-link.tsx +44 -0
  95. package/blocks/crm/components/contacts-screen.tsx +31 -13
  96. package/blocks/crm/components/dedupe-review.tsx +6 -1
  97. package/blocks/crm/components/import-wizard.tsx +3 -7
  98. package/blocks/crm/components/list-select.tsx +20 -0
  99. package/blocks/crm/components/record-engagement-tab.tsx +103 -0
  100. package/blocks/crm/components/record-form.tsx +1 -1
  101. package/blocks/crm/components/tasks-screen.tsx +35 -15
  102. package/blocks/crm/hooks/use-all-lists.ts +18 -0
  103. package/blocks/crm/hooks/use-page-query.ts +3 -1
  104. package/blocks/crm/lib/crm-types.ts +14 -0
  105. package/blocks/crm/lib/read-lists.ts +16 -0
  106. package/blocks/deals/README.md +24 -6
  107. package/blocks/deals/block.json +151 -2
  108. package/blocks/deals/components/close-deal-dialog.tsx +169 -0
  109. package/blocks/deals/components/deal-contacts-editor.tsx +134 -0
  110. package/blocks/deals/components/deal-detail.tsx +2 -2
  111. package/blocks/deals/components/deal-filters.tsx +65 -0
  112. package/blocks/deals/components/deal-line-items.tsx +120 -0
  113. package/blocks/deals/components/deal-notes-tab.tsx +97 -0
  114. package/blocks/deals/components/deal-related.tsx +30 -9
  115. package/blocks/deals/components/deal-tasks-tab.tsx +88 -0
  116. package/blocks/deals/components/deals-screen.tsx +57 -22
  117. package/blocks/deals/components/forecast-view.tsx +4 -4
  118. package/blocks/deals/components/pipeline-board.tsx +8 -6
  119. package/blocks/deals/components/pipeline-editor.tsx +132 -0
  120. package/blocks/deals/components/record-deals-tab.tsx +62 -0
  121. package/blocks/deals/components/stage-editor.tsx +1 -1
  122. package/blocks/deals/hooks/use-pipeline-deals.ts +17 -4
  123. package/blocks/deals/lib/deal-extras.ts +156 -0
  124. package/blocks/dialer/README.md +14 -5
  125. package/blocks/dialer/block.json +17 -2
  126. package/blocks/dialer/components/call-contact-button.tsx +26 -9
  127. package/blocks/dialer/components/call-log-screen.tsx +4 -1
  128. package/blocks/dialer/components/call-log.tsx +16 -4
  129. package/blocks/dialer/components/call-queue.tsx +3 -3
  130. package/blocks/dialer/components/dialer-screen.tsx +69 -27
  131. package/blocks/dialer/components/disposition-panel.tsx +1 -1
  132. package/blocks/dialer/components/record-calls-tab.tsx +101 -0
  133. package/blocks/dialer/components/session-start-form.tsx +58 -11
  134. package/blocks/dialer/components/softphone.tsx +1 -1
  135. package/blocks/enrichment/README.md +16 -6
  136. package/blocks/enrichment/block.json +19 -7
  137. package/blocks/enrichment/components/bulk-enrich-panel.tsx +76 -27
  138. package/blocks/enrichment/components/email-verify-badge.tsx +3 -3
  139. package/blocks/enrichment/components/enrich-button.tsx +1 -1
  140. package/blocks/enrichment/components/enrichment-screen.tsx +70 -17
  141. package/blocks/enrichment/components/waterfall-config.tsx +27 -7
  142. package/blocks/enrichment/lib/enrichment-types.ts +39 -0
  143. package/blocks/enrichment/recipes/bulk-enrich-and-poll.ts +15 -4
  144. package/blocks/forms/README.md +8 -0
  145. package/blocks/forms/block.json +107 -0
  146. package/blocks/forms/components/form-activity.tsx +71 -0
  147. package/blocks/forms/components/form-backfill.tsx +108 -0
  148. package/blocks/forms/components/form-builder.tsx +1 -1
  149. package/blocks/forms/components/form-embed.tsx +1 -1
  150. package/blocks/forms/components/form-groups.tsx +151 -0
  151. package/blocks/forms/components/form-mapping-editor.tsx +6 -9
  152. package/blocks/forms/components/form-route-history.tsx +72 -0
  153. package/blocks/forms/components/form-saved-views.tsx +81 -0
  154. package/blocks/forms/components/forms-screen.tsx +51 -17
  155. package/blocks/forms/components/more-rows.tsx +30 -0
  156. package/blocks/forms/components/submissions-export.tsx +67 -0
  157. package/blocks/forms/components/submissions-table.tsx +15 -6
  158. package/blocks/forms/lib/forms-extra.ts +67 -0
  159. package/blocks/inbox/README.md +5 -0
  160. package/blocks/inbox/block.json +15 -0
  161. package/blocks/inbox/components/record-conversations-tab.tsx +154 -0
  162. package/blocks/inbox/components/thread-badge.tsx +2 -2
  163. package/blocks/inbox/components/thread-view.tsx +1 -1
  164. package/blocks/integrations/README.md +54 -1
  165. package/blocks/integrations/block.json +189 -15
  166. package/blocks/integrations/components/ad-accounts.tsx +82 -0
  167. package/blocks/integrations/components/connection-health.tsx +59 -0
  168. package/blocks/integrations/components/crm-detail.tsx +20 -1
  169. package/blocks/integrations/components/crm-records.tsx +103 -0
  170. package/blocks/integrations/components/field-mappings.tsx +61 -0
  171. package/blocks/integrations/components/integration-list.tsx +4 -4
  172. package/blocks/integrations/components/integrations-screen.tsx +20 -3
  173. package/blocks/integrations/components/migration-panel.tsx +133 -0
  174. package/blocks/integrations/components/sugarcrm-connect.tsx +95 -0
  175. package/blocks/integrations/components/sync-status.tsx +6 -6
  176. package/blocks/integrations/components/sync-triggers.tsx +134 -0
  177. package/blocks/integrations/components/webhook-detail.tsx +26 -7
  178. package/blocks/integrations/components/webhook-manager.tsx +30 -17
  179. package/blocks/integrations/components/workspace-tools.tsx +127 -0
  180. package/blocks/integrations/lib/crm-utils.ts +39 -0
  181. package/blocks/integrations/lib/facts.tsx +13 -0
  182. package/blocks/integrations/lib/integrations-utils.ts +5 -0
  183. package/blocks/integrations/lib/load-more.tsx +141 -0
  184. package/blocks/intent/components/account-intent-score.tsx +5 -5
  185. package/blocks/intent/components/intent-feed.tsx +8 -8
  186. package/blocks/intent/components/intent-keyword-manager.tsx +8 -8
  187. package/blocks/intent/lib/intent-utils.ts +4 -4
  188. package/blocks/lifecycle/components/account-related.tsx +8 -1
  189. package/blocks/lifecycle/components/churn-list.tsx +5 -2
  190. package/blocks/lifecycle/components/customer-table.tsx +1 -1
  191. package/blocks/lists/README.md +7 -1
  192. package/blocks/lists/block.json +31 -3
  193. package/blocks/lists/components/add-company-people-to-list-button.tsx +73 -0
  194. package/blocks/lists/components/add-to-list-button.tsx +17 -7
  195. package/blocks/lists/components/list-picker.tsx +13 -3
  196. package/blocks/lists/components/list-table.tsx +1 -1
  197. package/blocks/lists/components/lists-screen.tsx +4 -2
  198. package/blocks/lists/components/record-lists-tab.tsx +99 -0
  199. package/blocks/lists/components/suppression-manager.tsx +27 -6
  200. package/blocks/lists/components/suppressions-screen.tsx +21 -12
  201. package/blocks/mailboxes/block.json +4 -0
  202. package/blocks/mailboxes/components/connect-mailbox.tsx +9 -8
  203. package/blocks/mailboxes/components/domain-detail.tsx +10 -4
  204. package/blocks/mailboxes/components/domain-purchase.tsx +14 -11
  205. package/blocks/mailboxes/components/mailbox-detail.tsx +3 -2
  206. package/blocks/mailboxes/components/mailbox-list.tsx +8 -3
  207. package/blocks/mailboxes/components/mailboxes-screen.tsx +8 -3
  208. package/blocks/mailboxes/components/warmup-health.tsx +20 -20
  209. package/blocks/mailboxes/lib/mailboxes-utils.ts +4 -4
  210. package/blocks/mailboxes/lib/use-provider-domains.ts +23 -0
  211. package/blocks/newsletter/README.md +12 -0
  212. package/blocks/newsletter/block.json +117 -0
  213. package/blocks/newsletter/components/issue-scheduler.tsx +2 -2
  214. package/blocks/newsletter/components/issue-versions.tsx +93 -0
  215. package/blocks/newsletter/components/more-rows.tsx +30 -0
  216. package/blocks/newsletter/components/newsletter-editor.tsx +1 -1
  217. package/blocks/newsletter/components/newsletter-list-syncs.tsx +121 -0
  218. package/blocks/newsletter/components/newsletter-screen.tsx +36 -13
  219. package/blocks/newsletter/components/newsletter-settings.tsx +211 -0
  220. package/blocks/newsletter/components/newsletter-stats.tsx +2 -2
  221. package/blocks/newsletter/components/newsletter-templates.tsx +133 -0
  222. package/blocks/newsletter/components/subscriber-table.tsx +5 -7
  223. package/blocks/newsletter/components/subscriber-timeline.tsx +65 -0
  224. package/blocks/newsletter/lib/newsletter-extra.ts +60 -0
  225. package/blocks/nurture/components/nurture-builder.tsx +10 -2
  226. package/blocks/nurture/components/nurture-enroll.tsx +6 -7
  227. package/blocks/nurture/components/nurture-form.tsx +5 -8
  228. package/blocks/nurture/components/nurture-screen.tsx +16 -5
  229. package/blocks/nurture/components/nurture-stats.tsx +5 -4
  230. package/blocks/objects/README.md +1 -1
  231. package/blocks/objects/block.json +13 -5
  232. package/blocks/objects/components/attribute-field-input.tsx +14 -1
  233. package/blocks/objects/components/object-record-detail.tsx +8 -1
  234. package/blocks/objects/components/object-schema-editor.tsx +1 -1
  235. package/blocks/objects/components/record-roles-panel.tsx +15 -5
  236. package/blocks/pages/README.md +14 -0
  237. package/blocks/pages/block.json +113 -1
  238. package/blocks/pages/components/more-rows.tsx +30 -0
  239. package/blocks/pages/components/page-ai-tools.tsx +196 -0
  240. package/blocks/pages/components/page-analytics.tsx +176 -0
  241. package/blocks/pages/components/page-clone.tsx +99 -0
  242. package/blocks/pages/components/page-domains.tsx +167 -0
  243. package/blocks/pages/components/page-editor.tsx +1 -1
  244. package/blocks/pages/components/page-generation-status.tsx +48 -0
  245. package/blocks/pages/components/page-tracking.tsx +137 -0
  246. package/blocks/pages/components/pages-screen.tsx +57 -16
  247. package/blocks/pages/components/seo-report.tsx +6 -1
  248. package/blocks/pages/lib/studio.ts +7 -0
  249. package/blocks/phone-numbers/README.md +74 -0
  250. package/blocks/phone-numbers/block.json +262 -0
  251. package/blocks/phone-numbers/components/a2p-registration.tsx +230 -0
  252. package/blocks/phone-numbers/components/assign-number.tsx +104 -0
  253. package/blocks/phone-numbers/components/number-search.tsx +239 -0
  254. package/blocks/phone-numbers/components/number-settings.tsx +134 -0
  255. package/blocks/phone-numbers/components/number-usage.tsx +97 -0
  256. package/blocks/phone-numbers/components/phone-number-detail.tsx +84 -0
  257. package/blocks/phone-numbers/components/phone-number-list.tsx +110 -0
  258. package/blocks/phone-numbers/components/phone-numbers-screen.tsx +201 -0
  259. package/blocks/phone-numbers/components/register-owned-number.tsx +98 -0
  260. package/blocks/phone-numbers/components/release-number.tsx +126 -0
  261. package/blocks/phone-numbers/hooks/use-phone-numbers.ts +58 -0
  262. package/blocks/phone-numbers/lib/phone-numbers-utils.ts +202 -0
  263. package/blocks/phone-numbers/recipes/01-search-and-buy-number.ts +49 -0
  264. package/blocks/phone-numbers/recipes/02-assign-number-to-rep.ts +25 -0
  265. package/blocks/phone-numbers/recipes/03-audit-numbers.ts +36 -0
  266. package/blocks/phone-numbers/recipes/04-register-text-messaging.ts +39 -0
  267. package/blocks/pickers/README.md +23 -0
  268. package/blocks/pickers/block.json +103 -0
  269. package/blocks/pickers/components/company-picker.tsx +90 -0
  270. package/blocks/pickers/components/contact-picker.tsx +131 -0
  271. package/blocks/pickers/components/member-picker.tsx +99 -0
  272. package/blocks/pickers/components/search-picker.tsx +98 -0
  273. package/blocks/pickers/hooks/use-current-member.ts +104 -0
  274. package/blocks/pickers/hooks/use-picker-pages.ts +73 -0
  275. package/blocks/pickers/lib/use-debounced.ts +13 -0
  276. package/blocks/quotes/README.md +13 -0
  277. package/blocks/quotes/block.json +48 -1
  278. package/blocks/quotes/components/create-quote-from-deal.tsx +48 -0
  279. package/blocks/quotes/components/quote-builder.tsx +1 -1
  280. package/blocks/quotes/components/quote-lifecycle-actions.tsx +146 -0
  281. package/blocks/quotes/components/quote-list.tsx +5 -3
  282. package/blocks/quotes/components/quote-related.tsx +3 -2
  283. package/blocks/quotes/components/quote-viewer.tsx +7 -2
  284. package/blocks/quotes/components/record-quotes-tab.tsx +90 -0
  285. package/blocks/radar/components/competitor-battle-card.tsx +1 -1
  286. package/blocks/radar/components/radar-overview.tsx +4 -4
  287. package/blocks/radar/components/radar-screen.tsx +2 -2
  288. package/blocks/sequencer/README.md +6 -0
  289. package/blocks/sequencer/block.json +28 -1
  290. package/blocks/sequencer/components/add-to-sequence-button.tsx +82 -0
  291. package/blocks/sequencer/components/enroll-dialog.tsx +2 -2
  292. package/blocks/sequencer/components/record-sequences-tab.tsx +126 -0
  293. package/blocks/sequencer/components/sequence-builder.tsx +19 -20
  294. package/blocks/sequencer/components/sequence-detail.tsx +4 -2
  295. package/blocks/sequencer/components/sequence-enrollments.tsx +4 -2
  296. package/blocks/sequencer/components/sequence-list.tsx +1 -1
  297. package/blocks/sequencer/components/sequence-stats.tsx +8 -8
  298. package/blocks/sequencer/components/sequences-screen.tsx +3 -0
  299. package/blocks/sequencer/components/step-editor.tsx +11 -11
  300. package/blocks/sequencer/lib/sequencer-utils.ts +5 -5
  301. package/blocks/social-ads/README.md +8 -0
  302. package/blocks/social-ads/block.json +137 -4
  303. package/blocks/social-ads/components/audience-sync-detail.tsx +9 -3
  304. package/blocks/social-ads/components/audience-sync.tsx +6 -2
  305. package/blocks/social-ads/components/campaign-ads.tsx +166 -0
  306. package/blocks/social-ads/components/campaign-detail.tsx +6 -1
  307. package/blocks/social-ads/components/campaign-editor.tsx +134 -0
  308. package/blocks/social-ads/components/creative-concepts.tsx +74 -0
  309. package/blocks/social-ads/components/creative-detail.tsx +6 -1
  310. package/blocks/social-ads/components/creative-gallery.tsx +20 -2
  311. package/blocks/social-ads/components/creative-revisions.tsx +57 -0
  312. package/blocks/social-ads/components/creative-tools.tsx +176 -0
  313. package/blocks/social-ads/components/post-composer.tsx +4 -2
  314. package/blocks/social-ads/components/post-detail.tsx +3 -0
  315. package/blocks/social-ads/components/social-ideas.tsx +64 -0
  316. package/blocks/social-ads/components/social-performance.tsx +80 -0
  317. package/blocks/social-ads/components/social-screen.tsx +33 -6
  318. package/blocks/social-ads/lib/ads-extra.ts +67 -0
  319. package/blocks/surveys/README.md +91 -0
  320. package/blocks/surveys/block.json +299 -0
  321. package/blocks/surveys/components/account-nps.tsx +69 -0
  322. package/blocks/surveys/components/contact-survey-history.tsx +52 -0
  323. package/blocks/surveys/components/org-summary.tsx +82 -0
  324. package/blocks/surveys/components/sending-domains.tsx +113 -0
  325. package/blocks/surveys/components/survey-form.tsx +126 -0
  326. package/blocks/surveys/components/survey-responses.tsx +145 -0
  327. package/blocks/surveys/components/survey-results.tsx +72 -0
  328. package/blocks/surveys/components/survey-send-history.tsx +58 -0
  329. package/blocks/surveys/components/survey-send.tsx +162 -0
  330. package/blocks/surveys/components/survey-triggers.tsx +166 -0
  331. package/blocks/surveys/components/surveys-screen.tsx +234 -0
  332. package/blocks/surveys/lib/surveys-types.ts +228 -0
  333. package/blocks/surveys/recipes/create-an-nps-survey.ts +22 -0
  334. package/blocks/surveys/recipes/send-a-survey-and-follow-it.ts +27 -0
  335. package/blocks/surveys/recipes/survey-score-report.ts +27 -0
  336. package/blocks/visitors/components/snippet-install.tsx +18 -9
  337. package/blocks/visitors/components/traffic-chart.tsx +4 -4
  338. package/blocks/visitors/components/visitor-company-card.tsx +24 -14
  339. package/blocks/visitors/components/visitor-feed.tsx +5 -5
  340. package/blocks/webchat/components/chat-widget-config.tsx +1 -1
  341. package/blocks/webchat/components/conversation-list.tsx +1 -1
  342. package/blocks/workbench/components/column-recipe-editor.tsx +1 -1
  343. package/blocks/workbench/components/run-progress.tsx +1 -1
  344. package/blocks/workbench/components/workbench-grid.tsx +1 -1
  345. package/blocks/workbench/components/workbench-screen.tsx +15 -5
  346. package/blocks/workbench/components/workbench-table-list.tsx +14 -2
  347. package/blocks/workflows/components/workflow-editor.tsx +1 -1
  348. package/dist/cli.js +536 -392
  349. package/dist/index.browser.js +1 -1
  350. package/dist/index.browser.js.map +1 -1
  351. package/dist/index.browser.mjs +1 -1
  352. package/dist/index.browser.mjs.map +1 -1
  353. package/dist/index.d.mts +2 -2
  354. package/dist/index.d.ts +2 -2
  355. package/dist/index.js +1 -1
  356. package/dist/index.js.map +1 -1
  357. package/dist/index.mjs +1 -1
  358. package/dist/index.mjs.map +1 -1
  359. package/dist/react.browser.js +1 -1
  360. package/dist/react.browser.js.map +1 -1
  361. package/dist/react.browser.mjs +1 -1
  362. package/dist/react.browser.mjs.map +1 -1
  363. package/dist/react.d.mts +2 -2
  364. package/dist/react.d.ts +2 -2
  365. package/dist/react.js +1 -1
  366. package/dist/react.js.map +1 -1
  367. package/dist/react.mjs +1 -1
  368. package/dist/react.mjs.map +1 -1
  369. package/dist/types/blocks/brand.d.ts +11 -2
  370. package/dist/types/blocks/create.d.ts +7 -3
  371. package/dist/types/blocks/doctor.d.ts +1 -0
  372. package/dist/types/blocks/flows.d.ts +7 -1
  373. package/dist/types/blocks/orgBrand.d.ts +34 -0
  374. package/dist/types/cli.d.ts +3 -0
  375. package/dist/types/version.d.ts +2 -2
  376. package/docs/api/desk.d.ts +2 -2
  377. package/docs/api/desk.md +3 -3
  378. package/package.json +1 -1
  379. package/stacks/6sense/pages/companies.tsx.tmpl +21 -0
  380. package/stacks/6sense/pages/contacts.tsx.tmpl +19 -0
  381. package/stacks/6sense/pages/tasks.tsx.tmpl +19 -0
  382. package/stacks/6sense.json +17 -4
  383. package/stacks/apollo/pages/calls.tsx.tmpl +23 -0
  384. package/stacks/apollo/pages/companies.tsx.tmpl +19 -36
  385. package/stacks/apollo/pages/contacts.tsx.tmpl +29 -95
  386. package/stacks/apollo/pages/deals.tsx.tmpl +17 -0
  387. package/stacks/apollo/pages/inbox-contact.tsx.tmpl +13 -0
  388. package/stacks/apollo/pages/lists.tsx.tmpl +12 -0
  389. package/stacks/apollo/pages/meetings.tsx.tmpl +13 -0
  390. package/stacks/apollo/pages/sequences.tsx.tmpl +12 -0
  391. package/stacks/apollo/pages/tasks.tsx.tmpl +20 -0
  392. package/stacks/apollo.json +36 -8
  393. package/stacks/calendly/pages/companies.tsx.tmpl +19 -0
  394. package/stacks/calendly/pages/contacts.tsx.tmpl +21 -0
  395. package/stacks/calendly/pages/meetings.tsx.tmpl +13 -0
  396. package/stacks/calendly/pages/tasks.tsx.tmpl +19 -0
  397. package/stacks/calendly.json +8 -4
  398. package/stacks/clay/pages/companies.tsx.tmpl +17 -1
  399. package/stacks/clay/pages/contacts.tsx.tmpl +12 -0
  400. package/stacks/clay/pages/lists.tsx.tmpl +12 -0
  401. package/stacks/clay/pages/tasks.tsx.tmpl +19 -0
  402. package/stacks/clay.json +16 -3
  403. package/stacks/gong/pages/calls.tsx.tmpl +23 -0
  404. package/stacks/gong/pages/companies.tsx.tmpl +22 -0
  405. package/stacks/gong/pages/contacts.tsx.tmpl +32 -0
  406. package/stacks/gong/pages/lists.tsx.tmpl +12 -0
  407. package/stacks/gong/pages/meetings.tsx.tmpl +13 -0
  408. package/stacks/gong/pages/tasks.tsx.tmpl +19 -0
  409. package/stacks/gong.json +23 -6
  410. package/stacks/hootsuite/pages/companies.tsx.tmpl +22 -0
  411. package/stacks/hootsuite/pages/contacts.tsx.tmpl +28 -0
  412. package/stacks/hootsuite/pages/lists.tsx.tmpl +12 -0
  413. package/stacks/hootsuite/pages/tasks.tsx.tmpl +19 -0
  414. package/stacks/hootsuite.json +9 -4
  415. package/stacks/hubspot/pages/companies.tsx.tmpl +14 -35
  416. package/stacks/hubspot/pages/contacts.tsx.tmpl +29 -95
  417. package/stacks/hubspot/pages/deals.tsx.tmpl +10 -1
  418. package/stacks/hubspot/pages/inbox.tsx.tmpl +12 -1
  419. package/stacks/hubspot/pages/lifecycle.tsx.tmpl +23 -0
  420. package/stacks/hubspot/pages/lists.tsx.tmpl +12 -0
  421. package/stacks/hubspot/pages/meetings.tsx.tmpl +13 -0
  422. package/stacks/hubspot/pages/sequences.tsx.tmpl +12 -0
  423. package/stacks/hubspot/pages/tasks.tsx.tmpl +20 -0
  424. package/stacks/hubspot.json +21 -5
  425. package/stacks/intercom/pages/companies.tsx.tmpl +19 -0
  426. package/stacks/intercom/pages/contacts.tsx.tmpl +21 -0
  427. package/stacks/intercom/pages/inbox-contact.tsx.tmpl +13 -0
  428. package/stacks/intercom/pages/tasks.tsx.tmpl +19 -0
  429. package/stacks/intercom.json +8 -4
  430. package/stacks/mailchimp/pages/companies.tsx.tmpl +22 -0
  431. package/stacks/mailchimp/pages/contacts.tsx.tmpl +28 -0
  432. package/stacks/mailchimp/pages/lists.tsx.tmpl +12 -0
  433. package/stacks/mailchimp/pages/tasks.tsx.tmpl +19 -0
  434. package/stacks/mailchimp.json +20 -5
  435. package/stacks/outreach/pages/calls.tsx.tmpl +23 -0
  436. package/stacks/outreach/pages/companies.tsx.tmpl +22 -0
  437. package/stacks/outreach/pages/contacts.tsx.tmpl +27 -66
  438. package/stacks/outreach/pages/inbox-contact.tsx.tmpl +13 -0
  439. package/stacks/outreach/pages/lists.tsx.tmpl +12 -0
  440. package/stacks/outreach/pages/meetings.tsx.tmpl +13 -0
  441. package/stacks/outreach/pages/sequences.tsx.tmpl +12 -0
  442. package/stacks/outreach/pages/tasks.tsx.tmpl +19 -0
  443. package/stacks/outreach.json +25 -7
  444. package/stacks/pipedrive/pages/companies.tsx.tmpl +11 -35
  445. package/stacks/pipedrive/pages/contacts.tsx.tmpl +15 -35
  446. package/stacks/pipedrive/pages/deals.tsx.tmpl +10 -1
  447. package/stacks/pipedrive/pages/inbox.tsx.tmpl +12 -1
  448. package/stacks/pipedrive/pages/meetings.tsx.tmpl +13 -0
  449. package/stacks/pipedrive/pages/tasks.tsx.tmpl +20 -0
  450. package/stacks/pipedrive.json +4 -2
  451. package/stacks/salesforce/pages/companies.tsx.tmpl +14 -35
  452. package/stacks/salesforce/pages/contacts.tsx.tmpl +20 -35
  453. package/stacks/salesforce/pages/deals.tsx.tmpl +10 -1
  454. package/stacks/salesforce/pages/lifecycle.tsx.tmpl +23 -0
  455. package/stacks/salesforce/pages/lists.tsx.tmpl +12 -0
  456. package/stacks/salesforce/pages/tasks.tsx.tmpl +20 -0
  457. package/stacks/salesforce.json +7 -3
  458. package/stacks/zoominfo/pages/companies.tsx.tmpl +17 -1
  459. package/stacks/zoominfo/pages/contacts.tsx.tmpl +12 -0
  460. package/stacks/zoominfo/pages/lists.tsx.tmpl +12 -0
  461. package/stacks/zoominfo/pages/tasks.tsx.tmpl +19 -0
  462. package/stacks/zoominfo.json +16 -3
  463. package/styles/styles.css +1 -1
  464. package/templates/next-app/app/globals.css.tmpl +349 -64
  465. package/templates/next-app/components/app-shell.tsx.tmpl +6 -0
  466. package/templates/next-app/components/shell/quick-jump.tsx.tmpl +4 -0
@@ -0,0 +1,120 @@
1
+ "use client";
2
+
3
+ /**
4
+ * DealLineItems: the products on a deal: add, edit and remove lines, with per-line totals and the deal total.
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. GET /deals/{id}/line-items (deals.listDealLineItems) read - lines, derived amount, quote mismatch flag
8
+ * 2. PUT /deals/{id}/line-items { line_items } (deals.replaceDealLineItems) write - sends the FULL desired set (an empty list clears them)
9
+ * Money: unit prices are typed in dollars and sent as whole CENTS; the server recomputes each line total from
10
+ * unit price, quantity and discount. The editor appears only after the lines loaded, and Save stays disabled until
11
+ * something changed, so a record that has not loaded is never overwritten. Fields the screen does not show (catalog
12
+ * ids, description, billing frequency) are carried over untouched.
13
+ */
14
+ import { useEffect, useMemo, useRef, useState } from "react";
15
+ import { Plus, Trash2 } from "lucide-react";
16
+ import { Button, Input, Skeleton } from "@graph8/sdk/ui";
17
+ import { ApiError } from "@/g8/components/api-error";
18
+ import { g8Get, g8Put } from "@/g8/lib/g8-client";
19
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
20
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
21
+ import { dataOf } from "../lib/deals-types";
22
+ import { centsToDollarsText, dollarsToCents, formatCents, lineTotalCents, type DealLineItem, type LineItemsPayload } from "../lib/deal-extras";
23
+
24
+ export interface DealLineItemsProps {
25
+ dealId: string;
26
+ /** Called after the lines were saved, so a screen can refresh the deal amount. */
27
+ onSaved?: () => void;
28
+ className?: string;
29
+ }
30
+
31
+ interface Row {
32
+ key: string;
33
+ base: DealLineItem | null;
34
+ name: string;
35
+ quantity: string;
36
+ price: string;
37
+ discount: string;
38
+ }
39
+
40
+ const FREQUENCIES: Array<[string, string]> = [["one_time", "One time"], ["month", "Monthly"], ["quarter", "Quarterly"], ["year", "Yearly"]];
41
+ let keyCounter = 0;
42
+ const nextKey = () => `row-${keyCounter++}`;
43
+
44
+ const rowFrom = (li: DealLineItem): Row => ({ key: li.id ?? nextKey(), base: li, name: li.product_name, quantity: String(li.quantity), price: centsToDollarsText(li.unit_amount), discount: String(li.discount_pct ?? 0) });
45
+ const blankRow = (): Row => ({ key: nextKey(), base: null, name: "", quantity: "1", price: "", discount: "0" });
46
+
47
+ /** The line a row stands for, or null while a field is invalid. */
48
+ function parse(r: Row, sortOrder: number): DealLineItem | null {
49
+ const cents = dollarsToCents(r.price);
50
+ const quantity = Number(r.quantity);
51
+ const discount = Number(r.discount === "" ? 0 : r.discount);
52
+ if (!r.name.trim() || cents == null || cents > 1_000_000_000 || !Number.isInteger(quantity) || quantity < 1 || quantity > 10_000 || !Number.isFinite(discount) || discount < 0 || discount > 100) return null;
53
+ const { id: _id, ...kept } = (r.base ?? {}) as DealLineItem & { line_total?: number };
54
+ const { line_total: _total, ...carried } = kept as DealLineItem & { line_total?: number };
55
+ return { ...carried, product_name: r.name.trim(), quantity, unit_amount: cents, discount_pct: discount, sort_order: sortOrder };
56
+ }
57
+
58
+ export function DealLineItems({ dealId, onSaved, className }: DealLineItemsProps) {
59
+ const q = useG8Query(async (signal) => dataOf<LineItemsPayload>(await g8Get(`/deals/${encodeURIComponent(dealId)}/line-items`, undefined, { signal })), [dealId], { key: ["deals", "line-items", dealId], refetchOnFocus: false });
60
+ const [rows, setRows] = useState<Row[]>([]);
61
+ const baseline = useRef("[]");
62
+ const dirty = useRef(false);
63
+ // A different deal must not show the previous deal's lines.
64
+ useEffect(() => { dirty.current = false; setRows([]); baseline.current = "[]"; }, [dealId]);
65
+ // Unsaved edits win over a refreshed record: only an untouched editor follows the server.
66
+ useEffect(() => {
67
+ if (!q.data || dirty.current) return;
68
+ const next = (q.data.line_items ?? []).map(rowFrom);
69
+ setRows(next);
70
+ baseline.current = JSON.stringify(next.map((r) => [r.name, r.quantity, r.price, r.discount]));
71
+ }, [q.data]);
72
+
73
+ const edit = (key: string, change: Partial<Row>) => { dirty.current = true; setRows((all) => all.map((r) => (r.key === key ? { ...r, ...change } : r))); };
74
+ const parsed = rows.map((r, i) => parse(r, i));
75
+ const valid = parsed.every((p) => p !== null);
76
+ const changed = JSON.stringify(rows.map((r) => [r.name, r.quantity, r.price, r.discount])) !== baseline.current;
77
+ const currency = q.data?.currency;
78
+ const total = useMemo(() => parsed.reduce((sum, p) => sum + (p ? lineTotalCents(p) : 0), 0), [parsed]);
79
+
80
+ const save = useG8Mutation(
81
+ () => g8Put(`/deals/${encodeURIComponent(dealId)}/line-items`, { line_items: parsed as DealLineItem[] }),
82
+ { invalidates: [["deals", "line-items", dealId], ["deals", "detail", dealId], ["deals", "list"]], onSuccess: () => { dirty.current = false; void q.refetch(); onSaved?.(); } },
83
+ );
84
+
85
+ if (q.error) return <ApiError error={q.error} title="Could not load the line items" onRetry={() => void q.refetch()} />;
86
+ if (!q.data) return <Skeleton className="h-32 w-full" aria-label="Loading line items" />;
87
+ return (
88
+ <form className={`space-y-3 ${className ?? ""}`} onSubmit={(e) => { e.preventDefault(); if (changed && valid) void save.mutate(undefined); }}>
89
+ {q.data.amount_mismatch ? (
90
+ <p role="status" className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
91
+ The deal amount ({q.data.amount ?? 0}) and its linked quote ({q.data.linked_quote_total ?? 0}) disagree.
92
+ </p>
93
+ ) : null}
94
+ {rows.length === 0 ? <p className="text-sm text-muted-foreground">No line items yet. Add the products this deal is for.</p> : null}
95
+ <ul className="space-y-2" aria-label="Line items">
96
+ {rows.map((r, i) => {
97
+ const line = parsed[i];
98
+ return (
99
+ <li key={r.key} className="grid gap-2 rounded-md border p-2 sm:grid-cols-[1fr_5rem_7rem_5rem_7rem_auto] sm:items-center">
100
+ <Input aria-label={`Product ${i + 1}`} placeholder="Product" value={r.name} onChange={(e) => edit(r.key, { name: e.target.value })} />
101
+ <Input aria-label={`Quantity ${i + 1}`} type="number" min={1} max={10000} step={1} value={r.quantity} onChange={(e) => edit(r.key, { quantity: e.target.value })} />
102
+ <Input aria-label={`Unit price ${i + 1}`} type="number" min={0} step="0.01" placeholder="0.00" value={r.price} onChange={(e) => edit(r.key, { price: e.target.value })} />
103
+ <Input aria-label={`Discount percent ${i + 1}`} type="number" min={0} max={100} step="any" value={r.discount} onChange={(e) => edit(r.key, { discount: e.target.value })} />
104
+ <span className="text-right text-sm tabular-nums" aria-label={`Line total ${i + 1}`}>{line ? formatCents(lineTotalCents(line), currency) : "-"}</span>
105
+ <Button type="button" size="sm" variant="ghost" aria-label={`Remove line ${i + 1}`} onClick={() => { dirty.current = true; setRows((all) => all.filter((x) => x.key !== r.key)); }}><Trash2 className="size-4" aria-hidden /></Button>
106
+ {r.base?.billing_frequency && r.base.billing_frequency !== "one_time" ? <span className="text-xs text-muted-foreground sm:col-span-6">{FREQUENCIES.find(([v]) => v === r.base?.billing_frequency)?.[1] ?? r.base.billing_frequency} billing</span> : null}
107
+ </li>
108
+ );
109
+ })}
110
+ </ul>
111
+ <div className="flex flex-wrap items-center justify-between gap-2">
112
+ <Button type="button" size="sm" variant="outline" onClick={() => { dirty.current = true; setRows((all) => [...all, blankRow()]); }}><Plus className="size-4" aria-hidden />Add line</Button>
113
+ <p className="text-sm font-semibold tabular-nums" data-testid="line-items-total">Total {formatCents(total, currency)}</p>
114
+ </div>
115
+ {!valid ? <p className="text-xs text-muted-foreground">Each line needs a product, a whole quantity from 1 to 10,000, a price and a discount from 0 to 100.</p> : null}
116
+ {save.error ? <ApiError error={save.error} title="Could not save the line items" /> : null}
117
+ <div className="flex justify-end"><Button type="submit" loading={save.loading} disabled={!changed || !valid}>Save line items</Button></div>
118
+ </form>
119
+ );
120
+ }
@@ -0,0 +1,97 @@
1
+ "use client";
2
+
3
+ /**
4
+ * DealNotesTab: list, add, edit and delete the notes of one deal.
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. GET /deals/{id}/notes (notes.listDealNotes) read
8
+ * 2. POST /deals/{id}/notes { content } (notes.createDealNote) write
9
+ * 3. PUT /deals/{id}/notes/{note_id} (deals.updateDealNote) write - only when the text changed
10
+ * 4. DELETE /deals/{id}/notes/{note_id} (deals.deleteDealNote) DESTRUCTIVE -> ConfirmSpendDialog first
11
+ * A `read_only` note (projected from synced CRM activity) can be neither edited nor deleted.
12
+ */
13
+ import { useState } from "react";
14
+ import { StickyNote } from "lucide-react";
15
+ import { Button, Skeleton, Textarea } from "@graph8/sdk/ui";
16
+ import { ApiError } from "@/g8/components/api-error";
17
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
18
+ import { EmptyState } from "@/g8/components/empty-state";
19
+ import { g8Delete, g8Get, g8Post, g8Put } from "@/g8/lib/g8-client";
20
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
21
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
22
+ import { rowsOf, type DealNote } from "../lib/deal-extras";
23
+
24
+ export interface DealNotesTabProps {
25
+ dealId: string;
26
+ /** Called after any write, so a sibling activity timeline can reload. */
27
+ onChanged?: () => void;
28
+ className?: string;
29
+ }
30
+
31
+ const when = (iso: string | null | undefined): string => (iso ? new Date(iso).toLocaleString() : "");
32
+
33
+ export function DealNotesTab({ dealId, onChanged, className }: DealNotesTabProps) {
34
+ const base = `/deals/${encodeURIComponent(dealId)}/notes`;
35
+ const [draft, setDraft] = useState("");
36
+ const [editing, setEditing] = useState<{ id: string; original: string; content: string } | null>(null);
37
+ const [deleting, setDeleting] = useState<DealNote | null>(null);
38
+ const [busyId, setBusyId] = useState<string | null>(null);
39
+ const notes = useG8Query(async (signal) => rowsOf<DealNote>(await g8Get(base, undefined, { signal })), [dealId], { key: ["deals", "notes", dealId] });
40
+ const changed = () => { void notes.refetch(); onChanged?.(); };
41
+
42
+ const add = useG8Mutation((content: string) => g8Post(base, { content }), { invalidates: [["deals", "notes", dealId]], onSuccess: () => { setDraft(""); changed(); } });
43
+ const edit = useG8Mutation(async (v: { id: string; content: string }) => {
44
+ setBusyId(v.id);
45
+ try { await g8Put(`${base}/${encodeURIComponent(v.id)}`, { content: v.content }); } finally { setBusyId(null); }
46
+ }, { invalidates: [["deals", "notes", dealId]], onSuccess: () => { setEditing(null); changed(); } });
47
+ const remove = useG8Mutation(async (id: string) => {
48
+ setBusyId(id);
49
+ try { await g8Delete(`${base}/${encodeURIComponent(id)}`); } finally { setBusyId(null); }
50
+ }, { invalidates: [["deals", "notes", dealId]], onSuccess: () => { setDeleting(null); changed(); }, onError: () => setDeleting(null) });
51
+
52
+ const items = notes.data ?? [];
53
+ const unchanged = editing ? editing.content.trim() === editing.original.trim() : true;
54
+ return (
55
+ <div className={`flex flex-col gap-3 ${className ?? ""}`}>
56
+ <form className="flex flex-col gap-2" onSubmit={(e) => { e.preventDefault(); if (draft.trim()) void add.mutate(draft.trim()); }}>
57
+ <Textarea aria-label="New note" rows={3} placeholder="Write a note about this deal" value={draft} onChange={(e) => setDraft(e.target.value)} />
58
+ <div className="flex justify-end"><Button type="submit" size="sm" loading={add.loading} disabled={!draft.trim()}>Add note</Button></div>
59
+ </form>
60
+ {add.error ? <ApiError error={add.error} title="Could not add the note" /> : null}
61
+ {edit.error ? <ApiError error={edit.error} title="Could not save the note" /> : null}
62
+ {remove.error ? <ApiError error={remove.error} title="Could not delete the note" /> : null}
63
+ {notes.error ? <ApiError error={notes.error} title="Could not load notes" onRetry={() => void notes.refetch()} /> : null}
64
+ {notes.loading && !notes.data ? <Skeleton className="h-16 w-full" aria-label="Loading notes" /> : null}
65
+ {!notes.loading && !notes.error && items.length === 0 ? <EmptyState icon={<StickyNote className="size-8" />} title="No notes yet" description="Capture what was said, decided or promised." /> : null}
66
+ <ul className="flex flex-col gap-2" aria-label="Deal notes">
67
+ {items.map((n) => (
68
+ <li key={n.id} className="rounded-md border p-3 text-sm" data-testid="deal-note">
69
+ {editing?.id === n.id ? (
70
+ <form className="flex flex-col gap-2" onSubmit={(e) => { e.preventDefault(); if (!unchanged && editing.content.trim()) void edit.mutate({ id: n.id, content: editing.content.trim() }); }}>
71
+ <Textarea aria-label="Edit note" rows={3} value={editing.content} onChange={(e) => setEditing({ ...editing, content: e.target.value })} />
72
+ <div className="flex justify-end gap-2">
73
+ <Button type="button" size="sm" variant="outline" onClick={() => setEditing(null)}>Cancel</Button>
74
+ <Button type="submit" size="sm" loading={busyId === n.id && edit.loading} disabled={unchanged || !editing.content.trim()}>Save note</Button>
75
+ </div>
76
+ </form>
77
+ ) : (
78
+ <>
79
+ <p className="whitespace-pre-wrap">{n.content}</p>
80
+ <div className="mt-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
81
+ <span>{[n.created_by_name, when(n.updated_at ?? n.created_at)].filter(Boolean).join(" · ")}{n.read_only ? " · synced, read only" : ""}</span>
82
+ {n.read_only ? null : (
83
+ <span className="flex gap-1">
84
+ <Button size="sm" variant="ghost" aria-label={`Edit note: ${n.content.slice(0, 30)}`} onClick={() => setEditing({ id: n.id, original: n.content, content: n.content })}>Edit</Button>
85
+ <Button size="sm" variant="ghost" aria-label={`Delete note: ${n.content.slice(0, 30)}`} loading={busyId === n.id && remove.loading} onClick={() => setDeleting(n)}>Delete</Button>
86
+ </span>
87
+ )}
88
+ </div>
89
+ </>
90
+ )}
91
+ </li>
92
+ ))}
93
+ </ul>
94
+ <ConfirmSpendDialog open={deleting != null} tier="destructive" title="Delete this note?" description={deleting?.content.slice(0, 160)} confirmLabel="Delete note" affected={1} confirming={remove.loading} onConfirm={() => deleting && void remove.mutate(deleting.id)} onCancel={() => setDeleting(null)} />
95
+ </div>
96
+ );
97
+ }
@@ -8,11 +8,12 @@
8
8
  * DealContactsTab: GET /deals/{id} (deals.getDeal) read - the deal's contacts and roles
9
9
  * GET /contacts/{id} ... (crm ContactDetail, in a side sheet)
10
10
  * DealQuotesTab: GET /quotes?deal_id=&page=&limit (quotes.listQuotes) read - needs the quotes:read scope
11
+ * (`actions` renders above the list: pass the quotes block's CreateQuoteFromDeal there)
11
12
  * Nothing here is billable, external or destructive.
12
13
  */
13
- import { useState } from "react";
14
+ import { useState, type ReactNode } from "react";
14
15
  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 { Button, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
16
17
  import { ApiError } from "@/g8/components/api-error";
17
18
  import { EmptyState } from "@/g8/components/empty-state";
18
19
  import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
@@ -20,9 +21,19 @@ import { StatusPill, toneForStatus, humanizeStatus } from "@/g8/components/scree
20
21
  import { g8Get } from "@/g8/lib/g8-client";
21
22
  import { useG8Query } from "@/g8/hooks/use-g8-query";
22
23
  import { ContactDetail } from "@/g8/crm/components/contact-detail";
23
- import { dataOf, type Deal } from "../lib/deals-types";
24
+ import { dataOf, type Deal, type DealContact } from "../lib/deals-types";
24
25
 
25
- export function DealContactsTab({ dealId }: { dealId: string }) {
26
+ /** `renderContactActions` adds controls beside each contact (DealContactsEditor uses it for role, primary and remove). */
27
+ export interface DealContactsTabProps {
28
+ dealId: string;
29
+ renderContactActions?: (contact: DealContact, deal: Deal) => ReactNode;
30
+ /** Open the person in your contacts screen (adds an "Open in contacts" button to the contact sheet). */
31
+ onOpenContact?: (contactId: number) => void;
32
+ /** Forwarded to the contact sheet's "Open company" button. */
33
+ onOpenCompany?: (companyId: number) => void;
34
+ }
35
+
36
+ export function DealContactsTab({ dealId, renderContactActions, onOpenContact, onOpenCompany }: DealContactsTabProps) {
26
37
  const [open, setOpen] = useState<number | null>(null);
27
38
  const q = useG8Query(async (signal) => dataOf<Deal>(await g8Get(`/deals/${encodeURIComponent(dealId)}`, undefined, { signal })), [dealId], { key: ["deals", "detail", dealId] });
28
39
  if (q.error) return <ApiError error={q.error} title="Could not load the deal's contacts" onRetry={() => void q.refetch()} />;
@@ -33,7 +44,7 @@ export function DealContactsTab({ dealId }: { dealId: string }) {
33
44
  <>
34
45
  <ul className="divide-y rounded-md border" aria-label="Deal contacts">
35
46
  {contacts.map((c) => (
36
- <li key={c.id}>
47
+ <li key={c.id} className={renderContactActions ? "flex items-center gap-2 pr-2" : undefined}>
37
48
  <button type="button" className="flex w-full items-center gap-3 p-3 text-left hover:bg-muted" onClick={() => setOpen(c.id)}>
38
49
  <EntityAvatar name={c.name || c.email || "Contact"} size="md" />
39
50
  <span className="min-w-0 flex-1">
@@ -43,13 +54,19 @@ export function DealContactsTab({ dealId }: { dealId: string }) {
43
54
  {c.role ? <StatusPill tone="neutral">{humanizeStatus(c.role)}</StatusPill> : null}
44
55
  {q.data?.primary_contact?.id === c.id ? <StatusPill tone="info">Primary</StatusPill> : null}
45
56
  </button>
57
+ {renderContactActions && q.data ? renderContactActions(c, q.data) : null}
46
58
  </li>
47
59
  ))}
48
60
  </ul>
49
61
  <Sheet open={open != null} onOpenChange={(o) => { if (!o) setOpen(null); }}>
50
62
  <SheetContent className="w-full overflow-y-auto sm:max-w-2xl">
51
63
  <SheetHeader><SheetTitle>Contact</SheetTitle><SheetDescription>The full CRM record.</SheetDescription></SheetHeader>
52
- {open != null ? <ContactDetail contactId={open} /> : null}
64
+ {open != null ? (
65
+ <>
66
+ {onOpenContact ? <div className="px-4"><Button size="sm" variant="outline" onClick={() => onOpenContact(open)}>Open in contacts</Button></div> : null}
67
+ <ContactDetail contactId={open} onOpenCompany={onOpenCompany} />
68
+ </>
69
+ ) : null}
53
70
  </SheetContent>
54
71
  </Sheet>
55
72
  </>
@@ -58,16 +75,19 @@ export function DealContactsTab({ dealId }: { dealId: string }) {
58
75
 
59
76
  interface QuoteRow { id: string; title?: string | null; quote_number?: string | null; status?: string | null; total?: number | null; currency?: string | null }
60
77
 
61
- export function DealQuotesTab({ dealId, onOpenQuote }: { dealId: string; onOpenQuote?: (quoteId: string) => void }) {
78
+ /** `actions` renders above the list, for example the quotes block's "Create quote from this deal" button. */
79
+ export function DealQuotesTab({ dealId, onOpenQuote, actions, refreshKey }: { dealId: string; onOpenQuote?: (quoteId: string) => void; actions?: ReactNode; refreshKey?: number }) {
62
80
  const q = useG8Query(async (signal) => {
63
81
  const env = await g8Get("/quotes", { deal_id: dealId, page: 1, limit: 50 }, { signal });
64
82
  const inner = dataOf<{ items?: QuoteRow[] } | QuoteRow[]>(env);
65
83
  return Array.isArray(inner) ? inner : (inner.items ?? []);
66
- }, [dealId]);
84
+ }, [dealId, refreshKey]);
67
85
  if (q.error) return <ApiError error={q.error} title="Could not load quotes" onRetry={() => void q.refetch()} />;
68
86
  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." />;
87
+ if (q.data.length === 0) return <div className="space-y-3">{actions}<EmptyState icon={<FileText className="size-8" />} title="No quotes for this deal" description="Create one from the Quotes screen." /></div>;
70
88
  return (
89
+ <div className="space-y-3">
90
+ {actions}
71
91
  <Table>
72
92
  <TableHeader><TableRow><TableHead>Quote</TableHead><TableHead>Status</TableHead><TableHead className="text-right">Total</TableHead></TableRow></TableHeader>
73
93
  <TableBody>
@@ -83,5 +103,6 @@ export function DealQuotesTab({ dealId, onOpenQuote }: { dealId: string; onOpenQ
83
103
  ))}
84
104
  </TableBody>
85
105
  </Table>
106
+ </div>
86
107
  );
87
108
  }
@@ -0,0 +1,88 @@
1
+ "use client";
2
+
3
+ /**
4
+ * DealTasksTab: the follow-up tasks linked to one deal: list, add, complete and reopen.
5
+ *
6
+ * SDK operations, in order:
7
+ * 1. GET /tasks?entity_type=deal&entity_id={id}&status= (tasks.listAllTasks) read
8
+ * 2. POST /tasks { title, due_date, priority, entity_type: "deal", entity_id } (tasks.createGlobalTask) write
9
+ * 3. PATCH /tasks/{task_id} { status } (tasks.updateTask) write - open or completed
10
+ * Priority: 1 high, 2 medium, 3 low, 0 none. Due dates are date-only (YYYY-MM-DD) and shown in UTC.
11
+ */
12
+ import { useState } from "react";
13
+ import { ListChecks } from "lucide-react";
14
+ import { Button, Input, Skeleton } from "@graph8/sdk/ui";
15
+ import { ApiError } from "@/g8/components/api-error";
16
+ import { EmptyState } from "@/g8/components/empty-state";
17
+ import { g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
18
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
19
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
20
+ import { rowsOf, type DealTask } from "../lib/deal-extras";
21
+
22
+ export interface DealTasksTabProps {
23
+ dealId: string;
24
+ onChanged?: () => void;
25
+ className?: string;
26
+ }
27
+
28
+ const PRIORITY = ["", "High", "Medium", "Low", "Low"];
29
+
30
+ export function DealTasksTab({ dealId, onChanged, className }: DealTasksTabProps) {
31
+ const [title, setTitle] = useState("");
32
+ const [due, setDue] = useState("");
33
+ const [priority, setPriority] = useState(2);
34
+ const [showDone, setShowDone] = useState(false);
35
+ const [busyId, setBusyId] = useState<string | null>(null);
36
+ const tasks = useG8Query(
37
+ async (signal) => rowsOf<DealTask>(await g8Get("/tasks", { entity_type: "deal", entity_id: dealId, status: showDone ? undefined : "open", limit: 100 }, { signal })),
38
+ [dealId, showDone],
39
+ { key: ["tasks", "deal", dealId, { showDone }] },
40
+ );
41
+ const changed = () => { void tasks.refetch(); onChanged?.(); };
42
+ const add = useG8Mutation(
43
+ () => g8Post("/tasks", { title: title.trim(), priority, entity_type: "deal", entity_id: dealId, ...(due ? { due_date: due } : {}) }),
44
+ { invalidates: [["tasks"]], onSuccess: () => { setTitle(""); setDue(""); changed(); } },
45
+ );
46
+ const toggle = useG8Mutation(async (t: DealTask) => {
47
+ setBusyId(t.id);
48
+ try { await g8Patch(`/tasks/${encodeURIComponent(t.id)}`, { status: t.status === "completed" ? "open" : "completed" }); } finally { setBusyId(null); }
49
+ }, { invalidates: [["tasks"]], onSuccess: changed });
50
+
51
+ const items = tasks.data ?? [];
52
+ return (
53
+ <div className={`flex flex-col gap-3 ${className ?? ""}`}>
54
+ <form className="flex flex-wrap items-end gap-2" onSubmit={(e) => { e.preventDefault(); if (title.trim()) void add.mutate(undefined); }}>
55
+ <Input aria-label="Task title" placeholder="Add a task for this deal" className="min-w-48 flex-1" value={title} onChange={(e) => setTitle(e.target.value)} />
56
+ <Input aria-label="Due date" type="date" className="w-40" value={due} onChange={(e) => setDue(e.target.value)} />
57
+ <select aria-label="Priority" className="h-9 rounded-md border bg-background px-2 text-sm" value={priority} onChange={(e) => setPriority(Number(e.target.value))}>
58
+ <option value={1}>High</option><option value={2}>Medium</option><option value={3}>Low</option><option value={0}>None</option>
59
+ </select>
60
+ <Button type="submit" size="sm" loading={add.loading} disabled={!title.trim()}>Add task</Button>
61
+ </form>
62
+ <label className="flex items-center gap-2 text-sm text-muted-foreground">
63
+ <input type="checkbox" checked={showDone} onChange={(e) => setShowDone(e.target.checked)} /> Show completed
64
+ </label>
65
+ {add.error ? <ApiError error={add.error} title="Could not add the task" /> : null}
66
+ {toggle.error ? <ApiError error={toggle.error} title="Could not update the task" /> : null}
67
+ {tasks.error ? <ApiError error={tasks.error} title="Could not load tasks" onRetry={() => void tasks.refetch()} /> : null}
68
+ {tasks.loading && !tasks.data ? <Skeleton className="h-12 w-full" aria-label="Loading tasks" /> : null}
69
+ {!tasks.loading && !tasks.error && items.length === 0 ? <EmptyState icon={<ListChecks className="size-8" />} title="No tasks" description="Add a follow-up so this deal does not stall." /> : null}
70
+ <ul className="flex flex-col gap-2" aria-label="Deal tasks">
71
+ {items.map((t) => {
72
+ const completed = t.status === "completed";
73
+ return (
74
+ <li key={t.id} className="flex items-center gap-3 rounded-md border p-3" data-testid="deal-task">
75
+ <input type="checkbox" aria-label={`${completed ? "Reopen" : "Complete"} ${t.title}`} checked={completed} disabled={busyId === t.id} onChange={() => void toggle.mutate(t)} />
76
+ <div className="min-w-0 flex-1">
77
+ <p className={`truncate text-sm ${completed ? "text-muted-foreground line-through" : "font-medium"}`}>{t.title}</p>
78
+ <p className="text-xs text-muted-foreground">
79
+ {[t.due_date ? `Due ${new Date(t.due_date.length === 10 ? `${t.due_date}T00:00:00Z` : t.due_date).toLocaleDateString(undefined, { timeZone: "UTC" })}` : null, PRIORITY[t.priority ?? 0] || null, t.assignee_name].filter(Boolean).join(" · ")}
80
+ </p>
81
+ </div>
82
+ </li>
83
+ );
84
+ })}
85
+ </ul>
86
+ </div>
87
+ );
88
+ }
@@ -5,21 +5,28 @@
5
5
  * pipeline stage settings in one screen. No props are required.
6
6
  *
7
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.
8
+ * ForecastView, StageEditor, PipelineEditor, DealContactsEditor, DealLineItems, DealNotesTab, DealTasksTab, DealQuotesTab,
9
+ * DealActions (close with a reason, move pipeline), DealFilterBar and the crm block's ActivityTimeline and ContactDetail.
9
10
  *
10
11
  * Flows:
11
12
  * - Board / List toggle, search by deal name, switch pipeline (when there is more than one).
12
13
  * - Headline numbers: open deals, open value, estimated weighted value, won value (from the loaded deals).
14
+ * - Filters (board and list): owner, stage, outcome, stale (no activity for 7 to 90 days), activity, close-date range
15
+ * and "no contact".
13
16
  * - 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.
17
+ * edit, stage history, confirmed delete), Contacts (add, set role, make primary, remove), Line items (add, edit,
18
+ * remove, totals), Notes (add, edit, delete), Tasks (add, complete), Quotes and Activity tabs. The header has
19
+ * "Close deal" (won or lost with a reason) and "Move to pipeline". Moving a card into a won/lost stage asks for the close date and amount.
15
20
  * - New deal: name, amount, close date, stage, plus the two things the API requires. The OWNER defaults to the API
16
21
  * key's own user (GET /team-members/me); with an organisation key it defaults to the first team member and an
17
22
  * owner picker lets the person change it. At least ONE CONTACT is required by the API (`contact_ids`), found with a
18
23
  * 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).
24
+ * - Forecast tab (ForecastView) and Pipeline settings tab (create, rename, default and delete pipelines, the close
25
+ * reasons, a stage summary with deal counts and values, plus StageEditor: names, probabilities, order, delete).
20
26
  *
21
27
  * SDK operations (all through the composed parts): listPipelines, listDeals, getDeal, listDealHistory, createDeal,
22
- * updateDeal, deleteDeal (confirmed), getDealAnalytics, getDealStageVelocity, the pipeline-stage writes,
28
+ * updateDeal, deleteDeal (confirmed), the notes, tasks, contact, line-item, close-reason and pipeline operations of the parts
29
+ * above, getDealAnalytics, getDealStageVelocity, the pipeline-stage writes,
23
30
  * listTeamMembers + getMyTeamMember (the owner default) and the crm block's listContacts + activity feed.
24
31
  * Nothing here is billable or external; delete is destructive and goes through ConfirmSpendDialog inside DealDetail.
25
32
  *
@@ -50,10 +57,18 @@ import { usePipelineDeals } from "../hooks/use-pipeline-deals";
50
57
  import { usePipelines } from "../hooks/use-pipelines";
51
58
  import { dataOf, formatDate, formatMoney, stageKind, sumMoney, weightedPipeline, type Deal, type Stage } from "../lib/deals-types";
52
59
  import { DealDetail } from "./deal-detail";
53
- import { DealContactsTab, DealQuotesTab } from "./deal-related";
60
+ import { DealActions } from "./close-deal-dialog";
61
+ import { DealContactsEditor } from "./deal-contacts-editor";
62
+ import { DealFilterBar } from "./deal-filters";
63
+ import { DealLineItems } from "./deal-line-items";
64
+ import { DealNotesTab } from "./deal-notes-tab";
65
+ import { DealQuotesTab } from "./deal-related";
66
+ import { DealTasksTab } from "./deal-tasks-tab";
54
67
  import { ForecastView } from "./forecast-view";
55
68
  import { PipelineBoard } from "./pipeline-board";
69
+ import { PipelineEditor } from "./pipeline-editor";
56
70
  import { StageEditor } from "./stage-editor";
71
+ import { NO_FILTERS, filterQuery, filtersActive, type DealFilterState } from "../lib/deal-extras";
57
72
  import { useBrand } from "@/g8/hooks/use-brand";
58
73
 
59
74
  export interface DealsScreenProps {
@@ -69,6 +84,12 @@ export interface DealsScreenProps {
69
84
  toolbarActions?: ReactNode;
70
85
  /** Open a quote of the deal in your quotes screen; without it the Quotes tab is read-only. */
71
86
  onOpenQuote?: (quoteId: string) => void;
87
+ /** Buttons above the Quotes tab list, for example the quotes block's `<CreateQuoteFromDeal dealId={deal.id} />`. */
88
+ renderQuoteActions?: (deal: Deal) => ReactNode;
89
+ /** Open a deal's contact in your contacts screen; without it the Contacts tab sheet has no such button. */
90
+ onOpenContact?: (contactId: number) => void;
91
+ /** Open the company of a deal's contact in your companies screen. */
92
+ onOpenCompany?: (companyId: number) => void;
72
93
  }
73
94
 
74
95
  type View = "board" | "list" | "forecast" | "stages";
@@ -201,7 +222,7 @@ function DealRows({ deals, stages, selectedId, onOpen, renderRowActions }: { dea
201
222
  );
202
223
  }
203
224
 
204
- export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, onOpenQuote }: DealsScreenProps) {
225
+ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, onOpenQuote, renderQuoteActions, onOpenContact, onOpenCompany }: DealsScreenProps) {
205
226
  const brand = useBrand();
206
227
  const [viewParam, setViewParam] = useUrlState("tab", "board");
207
228
  const [selectedId, setSelectedId] = useUrlState("id");
@@ -212,10 +233,16 @@ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, r
212
233
  const [creating, setCreating] = useState(false);
213
234
  const [version, setVersion] = useState(0);
214
235
  const view = asView(viewParam);
236
+ const [filterState, setFilterState] = useState<DealFilterState>(NO_FILTERS);
237
+ const filterKey = JSON.stringify(filterState);
238
+ // The stale cutoff is midnight UTC, so the object is stable all day; recompute only when a filter changes.
239
+ const filters = useMemo(() => filterQuery(filterState), [filterKey]); // eslint-disable-line react-hooks/exhaustive-deps
240
+ const owners = useDefaultOwner();
215
241
  const pipelines = usePipelines();
216
242
  const pipeline = pipelines.pipelines.find((p) => p.id === pipelineParam) ?? pipelines.defaultPipeline;
217
- const data = usePipelineDeals(pipeline?.id, { search });
243
+ const data = usePipelineDeals(pipeline?.id, { search, ...filters });
218
244
  const stages = pipeline?.stages ?? [];
245
+ const filtered = filtersActive(filterState);
219
246
  const refresh = () => { setVersion((v) => v + 1); void data.reload(); };
220
247
 
221
248
  const stats = useMemo(() => {
@@ -224,12 +251,12 @@ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, r
224
251
  const cur = data.deals[0]?.currency;
225
252
  const weighted = (group: ReadonlyArray<Deal>) => Math.round(weightedPipeline(group, stages).value);
226
253
  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 },
254
+ { label: "Open deals", value: open.length.toLocaleString(), ...(filtered ? { hint: "Filters applied" } : {}) },
255
+ { label: "Open value", value: sumMoney(open, undefined, cur), ...(filtered ? { hint: "Filters applied" } : {}) },
256
+ { label: "Weighted (estimate)", value: sumMoney(data.deals, weighted, cur), hint: filtered ? "Filters applied" : "Open deals times stage probability" },
257
+ { label: "Won", value: sumMoney(won, undefined, cur), tone: "positive" as const, ...(filtered ? { hint: "Filters applied" } : {}) },
231
258
  ];
232
- }, [data.deals, stages]);
259
+ }, [data.deals, stages, filtered]);
233
260
 
234
261
  const listed = data.deals.find((d) => d.id === selectedId);
235
262
  // A deep link can point at a deal in another pipeline or hidden by the search: resolve it directly.
@@ -244,14 +271,17 @@ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, r
244
271
  title={selected?.name || "Deal"}
245
272
  subtitle={selected ? [selected.stage_name, formatMoney(selected.amount, selected.currency)].join(" · ") : undefined}
246
273
  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}
274
+ actions={selected ? <>{pipelines.pipelines.length > 0 ? <DealActions deal={selected} pipelines={pipelines.pipelines} onChanged={refresh} /> : null}{renderDetailActions?.(selected)}</> : undefined}
248
275
  onClose={close}
249
276
  onExpand={mode === "full" ? undefined : () => setMode("full")}
250
277
  onCollapse={mode === "full" ? () => setMode(null) : undefined}
251
278
  tabs={[
252
279
  { 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} /> },
280
+ { id: "contacts", label: "Contacts", content: <DealContactsEditor key={selectedId} dealId={selectedId} onChanged={refresh} onOpenContact={onOpenContact} onOpenCompany={onOpenCompany} /> },
281
+ { id: "line-items", label: "Line items", content: <DealLineItems key={selectedId} dealId={selectedId} onSaved={refresh} /> },
282
+ { id: "notes", label: "Notes", content: <DealNotesTab key={selectedId} dealId={selectedId} /> },
283
+ { id: "tasks", label: "Tasks", content: <DealTasksTab key={selectedId} dealId={selectedId} /> },
284
+ { id: "quotes", label: "Quotes", content: <DealQuotesTab key={selectedId} dealId={selectedId} onOpenQuote={onOpenQuote} actions={selected && renderQuoteActions ? renderQuoteActions(selected) : undefined} refreshKey={version} /> },
255
285
  { id: "activity", label: "Activity", content: <ActivityTimeline entityType="deal" entityId={selectedId} /> },
256
286
  ...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: selected ? t.render(selected) : null })),
257
287
  ]}
@@ -264,8 +294,8 @@ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, r
264
294
 
265
295
  let body: ReactNode;
266
296
  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} />;
297
+ else if (view === "stages") body = pipeline ? <div className="space-y-6"><PipelineEditor onChanged={(e) => { if (e.kind === "deleted" && e.pipelineId === pipeline.id) { setPipelineParam(""); close(); } refresh(); }} /><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.`} />;
298
+ 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} filters={filters} onSelectDeal={(d) => open(d.id)} onChanged={() => void data.reload()} /> : list} />;
269
299
 
270
300
  return (
271
301
  <ScreenLayout
@@ -280,11 +310,16 @@ export function DealsScreen({ pipelineId: initialPipelineId, renderRowActions, r
280
310
  toolbar={showToolbar ? (
281
311
  <Toolbar
282
312
  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}
313
+ filters={(
314
+ <>
315
+ {pipelines.pipelines.length > 1 ? (
316
+ <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); setFilterState((f) => ({ ...f, stageId: "" })); close(); }}>
317
+ {pipelines.pipelines.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
318
+ </select>
319
+ ) : null}
320
+ <DealFilterBar value={filterState} onChange={(next) => { setFilterState(next); }} stages={stages} owners={owners.owners} />
321
+ </>
322
+ )}
288
323
  actions={toolbarActions}
289
324
  />
290
325
  ) : undefined}
@@ -42,7 +42,7 @@ interface Velocity {
42
42
  }
43
43
 
44
44
  function Tile({ label, value, hint }: { label: string; value: string; hint?: string }) {
45
- return <div className="rounded-lg border p-3"><div className="text-xs uppercase text-muted-foreground">{label}</div><div className="text-lg font-semibold">{value}</div>{hint ? <div className="text-xs text-muted-foreground">{hint}</div> : null}</div>;
45
+ return <div className="rounded-lg border p-3"><div className="text-xs uppercase text-muted-foreground">{label}</div><div className="text-lg font-medium">{value}</div>{hint ? <div className="text-xs text-muted-foreground">{hint}</div> : null}</div>;
46
46
  }
47
47
 
48
48
  const MAX_OPEN_DEALS = 2000;
@@ -84,7 +84,7 @@ export function ForecastView({ pipelineId, stuckAfterDays = 14, className }: For
84
84
  </div>
85
85
 
86
86
  <section aria-label="Deals by stage" className="space-y-1">
87
- <h3 className="text-sm font-semibold">Deals by stage</h3>
87
+ <h3 className="text-sm font-medium">Deals by stage</h3>
88
88
  {(analytics.data?.by_stage ?? []).map((s) => (
89
89
  <div key={`${s.stage_id}-${s.stage_name}`} className="flex items-center gap-2 text-sm">
90
90
  <span className="w-40 truncate">{s.stage_name}</span>
@@ -95,14 +95,14 @@ export function ForecastView({ pipelineId, stuckAfterDays = 14, className }: For
95
95
  </section>
96
96
 
97
97
  <section aria-label="Stage velocity" className="space-y-1 text-sm">
98
- <h3 className="font-semibold">Where deals stall</h3>
98
+ <h3 className="font-medium">Where deals stall</h3>
99
99
  {(velocity.data?.by_stage ?? []).map((s) => <p key={s.stage_id}>{s.stage_name}: {s.avg_days_in_stage != null ? `${s.avg_days_in_stage.toFixed(1)} days average` : "no data"}{s.conversion_rate != null ? `, ${Math.round(s.conversion_rate * 100)}% advance` : ""}, {s.still_in_stage_count} waiting</p>)}
100
100
  <p className="text-muted-foreground">{velocity.data?.stuck_deals?.total_count ?? 0} deals have not moved in {velocity.data?.stuck_deals?.threshold_days ?? stuckAfterDays}+ days.</p>
101
101
  </section>
102
102
 
103
103
  {(analytics.data?.trend ?? []).length > 0 ? (
104
104
  <section aria-label="Won and lost by month" className="space-y-1 text-sm">
105
- <h3 className="font-semibold">Won and lost by month</h3>
105
+ <h3 className="font-medium">Won and lost by month</h3>
106
106
  {(analytics.data?.trend ?? []).slice(-6).map((p) => <p key={p.period}>{p.period}: {p.won_count} won{p.won_value != null && !mixed ? ` (${formatMoney(p.won_value, code)})` : ""}, {p.lost_count} lost</p>)}
107
107
  </section>
108
108
  ) : null}