@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
@@ -9,7 +9,8 @@
9
9
  "create/edit form that renders the org's custom fields",
10
10
  "CSV/Excel import wizard with column mapping and preview",
11
11
  "duplicate review with merge, bulk merge and undo",
12
- "locked (masked) emails and phones shown as Locked, never as values"
12
+ "locked (masked) emails and phones shown as Locked, never as values",
13
+ "finished ContactsScreen, CompaniesScreen and TasksScreen (list, detail, create, edit, delete, import, duplicates)"
13
14
  ],
14
15
  "dependsOn": [
15
16
  "foundation"
@@ -24,6 +25,7 @@
24
25
  "create_company_task_companies__company_id__tasks_post",
25
26
  "create_contacts_contacts_post",
26
27
  "create_field_fields_post",
28
+ "create_global_task_tasks_post",
27
29
  "create_list_lists_post",
28
30
  "create_note_contacts__contact_id__notes_post",
29
31
  "create_task_contacts__contact_id__tasks_post",
@@ -36,7 +38,9 @@
36
38
  "get_bulk_merge_status_duplicates_bulk_merge__run_id__get",
37
39
  "get_company_companies__company_id__get",
38
40
  "get_contact_contacts__contact_id__get",
41
+ "get_task_tasks__task_id__get",
39
42
  "import_contacts_imports__import_id__contacts_post",
43
+ "list_all_tasks_tasks_get",
40
44
  "list_companies_companies_get",
41
45
  "list_company_fields_fields_companies_get",
42
46
  "list_company_notes_companies__company_id__notes_get",
@@ -256,6 +260,53 @@
256
260
  "write",
257
261
  "destructive"
258
262
  ]
263
+ },
264
+ {
265
+ "name": "ContactsScreen",
266
+ "file": "components/contacts-screen.tsx",
267
+ "purpose": "Finished contacts workspace: search, filters, sort, paging, bulk delete, detail panel with activity/notes/tasks, create, edit, CSV import and duplicate review. No required props; slot props for other blocks.",
268
+ "operations": [
269
+ "delete_contact_contacts__contact_id__delete",
270
+ "duplicate_counts_duplicates_counts_get",
271
+ "get_contact_contacts__contact_id__get",
272
+ "list_contacts_contacts_get"
273
+ ],
274
+ "tiers": [
275
+ "read",
276
+ "destructive"
277
+ ]
278
+ },
279
+ {
280
+ "name": "CompaniesScreen",
281
+ "file": "components/companies-screen.tsx",
282
+ "purpose": "Finished companies workspace: search, filters, sort, paging, bulk delete, detail panel with people/activity/notes/tasks, create, edit and duplicate review. No required props.",
283
+ "operations": [
284
+ "delete_company_companies__company_id__delete",
285
+ "duplicate_counts_duplicates_counts_get",
286
+ "get_company_companies__company_id__get",
287
+ "list_companies_companies_get"
288
+ ],
289
+ "tiers": [
290
+ "read",
291
+ "destructive"
292
+ ]
293
+ },
294
+ {
295
+ "name": "TasksScreen",
296
+ "file": "components/tasks-screen.tsx",
297
+ "purpose": "Workspace-wide task queue: Open/Completed/All, search and filters, complete or reopen, create, edit, delete, bulk complete and delete, detail panel.",
298
+ "operations": [
299
+ "list_all_tasks_tasks_get",
300
+ "get_task_tasks__task_id__get",
301
+ "create_global_task_tasks_post",
302
+ "update_task_tasks__task_id__patch",
303
+ "delete_task_tasks__task_id__delete"
304
+ ],
305
+ "tiers": [
306
+ "read",
307
+ "write",
308
+ "destructive"
309
+ ]
259
310
  }
260
311
  ],
261
312
  "hooks": [
@@ -283,6 +334,10 @@
283
334
  {
284
335
  "file": "lib/crm-types.ts",
285
336
  "purpose": "Record and field types plus helpers (isMasked, contactName, isCustomField) shared by the CRM components."
337
+ },
338
+ {
339
+ "file": "lib/screen-parts.tsx",
340
+ "purpose": "Sorting, row selection, bulk delete and pager shared by ContactsScreen, CompaniesScreen and TasksScreen."
286
341
  }
287
342
  ],
288
343
  "recipes": [
@@ -373,6 +428,7 @@
373
428
  }
374
429
  ],
375
430
  "peerDependencies": {
376
- "react": ">=18"
431
+ "react": ">=18",
432
+ "lucide-react": ">=0.400.0"
377
433
  }
378
434
  }
@@ -0,0 +1,345 @@
1
+ "use client";
2
+
3
+ /**
4
+ * CompaniesScreen: the finished accounts workspace. A searchable, filterable, sortable, paginated grid of companies on the
5
+ * left; the open company (people, timeline, notes, tasks, fields) beside it. No props are required.
6
+ *
7
+ * Composes: ScreenLayout, StatStrip, Toolbar, SplitView, DetailPanel, FormSheet (screen kit) with RecordForm,
8
+ * ContactTable and RecordForm (the People tab and "Add person"), ActivityTimeline, NotesPanel, TaskList and DedupeReview (this block).
9
+ *
10
+ * Flows: search and filter (name, domain, industry, country; debounced) - sort the loaded page - paginate - open a company
11
+ * (deep link ?id=, full page with &view=full) - create a company (a domain is required; an existing one is merged) - edit - delete one or many (confirm,
12
+ * "N of M" report, 404 = already gone) - see, open and add its people - add notes and tasks - review and merge duplicates -
13
+ * hand a company to other blocks through the slot props.
14
+ *
15
+ * SDK operations: none of its own. listCompanies, getCompany, getActivityFeed, duplicateCounts are `read`; forms, notes and
16
+ * tasks are `write`; deleteCompany (rows, detail and bulk) is `destructive` and always goes through ConfirmSpendDialog.
17
+ * Company import is not part of the API (the import wizard writes contacts, which create their companies by domain).
18
+ */
19
+
20
+ import { useEffect, useMemo, useState } from "react";
21
+ import type { ReactNode } from "react";
22
+ import { Button, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
23
+ import { Building2, GitMerge, Plus, Trash2, UserPlus } from "lucide-react";
24
+ import { ApiError } from "@/g8/components/api-error";
25
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
26
+ import { EmptyState } from "@/g8/components/empty-state";
27
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
28
+ import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
29
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
30
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
31
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
32
+ import { SplitView } from "@/g8/components/screen/split-view";
33
+ import { Toolbar } from "@/g8/components/screen/toolbar";
34
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
35
+ import { rowActivationProps } from "@/g8/hooks/use-row-activation";
36
+ import { RecordLink } from "@/g8/components/screen/record-link";
37
+ import { plural } from "@/g8/lib/plural";
38
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
39
+ import { useUrlState } from "@/g8/hooks/use-url-state";
40
+ import { g8Delete, g8Get } from "@/g8/lib/g8-client";
41
+ import { useDebouncedValue } from "../hooks/use-debounced";
42
+ import { usePageQuery } from "../hooks/use-page-query";
43
+ import { companyName, errorStatus, location } from "../lib/crm-types";
44
+ import type { CompanyRecord, ContactRecord, ScreenActionContext } from "../lib/crm-types";
45
+ import { Pager, SortHead, formatDay, sortRows, useBulkDelete, useRowSelection, useSort } from "../lib/screen-parts";
46
+ import { ActivityTimeline } from "./activity-timeline";
47
+ import { ContactTable } from "./contact-table";
48
+ import { DedupeReview } from "./dedupe-review";
49
+ import { NotesPanel } from "./notes-panel";
50
+ import { RecordForm } from "./record-form";
51
+ import { TaskList } from "./task-list";
52
+
53
+ export interface CompaniesScreenProps {
54
+ pageSize?: number;
55
+ /** Buttons at the end of each row. */
56
+ renderRowActions?: (row: CompanyRecord, ctx: ScreenActionContext) => ReactNode;
57
+ /** Buttons in the open company's header. */
58
+ renderDetailActions?: (company: CompanyRecord, ctx: ScreenActionContext) => ReactNode;
59
+ /** Extra tabs on the open company. */
60
+ extraDetailTabs?: Array<{ id: string; label: string; render: (company: CompanyRecord) => ReactNode }>;
61
+ /** Extra buttons in the page header, before "New company". */
62
+ toolbarActions?: ReactNode;
63
+ /** Extra bulk actions next to Delete. */
64
+ renderBulkActions?: (selection: { ids: number[]; rows: CompanyRecord[]; clear: () => void }) => ReactNode;
65
+ /** Called when a person in the People tab is clicked. Without it the rows are not clickable. */
66
+ onOpenContact?: (contact: ContactRecord) => void;
67
+ className?: string;
68
+ }
69
+
70
+ interface Filters { name: string; domain: string; industry: string; country: string }
71
+ const NO_FILTERS: Filters = { name: "", domain: "", industry: "", country: "" };
72
+
73
+ function sortValue(row: CompanyRecord, key: string): string | null | undefined {
74
+ if (key === "name") return row.name || row.domain;
75
+ if (key === "domain") return row.domain;
76
+ if (key === "industry") return row.industry;
77
+ if (key === "employees") return row.employee_count;
78
+ if (key === "location") return location(row);
79
+ return null;
80
+ }
81
+
82
+ const dash = <span className="text-muted-foreground">-</span>;
83
+
84
+ export function CompaniesScreen(props: CompaniesScreenProps) {
85
+ const { pageSize = 25 } = props;
86
+ const [idParam, setIdParamRaw] = useUrlState("id");
87
+ const [view, setView] = useUrlState("view");
88
+ const full = view === "full" && idParam != null && idParam !== "";
89
+ /** Changing or clearing the open record also leaves the full-page view. */
90
+ const setIdParam = (v: string | null): void => { if (v == null) setView(null); setIdParamRaw(v); };
91
+ const parsedId = idParam ? Number(idParam) : null;
92
+ /** A malformed deep link (`?id=abc`) opens nothing instead of fetching /companies/NaN. */
93
+ const openId = parsedId != null && Number.isFinite(parsedId) ? parsedId : null;
94
+ const [draft, setDraft] = useState<Filters>(NO_FILTERS);
95
+ const filters = useDebouncedValue(draft);
96
+ const [sheet, setSheet] = useState<"new" | "dupes" | null>(null);
97
+ const [confirmDelete, setConfirmDelete] = useState(false);
98
+ const [note, setNote] = useState<string | null>(null);
99
+ const { sort, toggle: toggleSort } = useSort();
100
+
101
+ const list = usePageQuery<CompanyRecord>(
102
+ (page, signal) => g8Get<CompanyRecord[]>("/companies", { page, limit: pageSize, ...Object.fromEntries(Object.entries(filters).filter(([, v]) => v)) }, { signal }),
103
+ [filters],
104
+ pageSize,
105
+ ["companies", "list", "screen"],
106
+ );
107
+ const dupes = useG8Query((signal) => g8Get<{ pending?: number; count?: number }>("/duplicates/counts", { entity_type: "company" }, { signal }), [], { key: ["duplicates", "counts", "company"] });
108
+ const selection = useRowSelection<CompanyRecord>(JSON.stringify(filters));
109
+ const rows = useMemo(() => sortRows(list.rows, sort, sortValue), [list.rows, sort]);
110
+ const hasFilters = Object.values(draft).some(Boolean);
111
+ const pending = dupes.data?.data.pending ?? dupes.data?.data.count;
112
+ const industries = new Set(list.rows.map((r) => r.industry).filter(Boolean)).size;
113
+ const reload = () => { void list.refetch(); void dupes.refetch(); };
114
+ const set = (key: keyof Filters, v: string) => setDraft((f) => ({ ...f, [key]: v }));
115
+ const allOnPage = rows.length > 0 && rows.every((r) => selection.selected.has(r.id));
116
+
117
+ const bulk = useBulkDelete("/companies", (gone, total) => {
118
+ selection.drop(gone);
119
+ setConfirmDelete(false);
120
+ setNote(gone.length < total ? `Deleted ${gone.length} of ${total}. ${total - gone.length} could not be deleted; they are still selected.` : null);
121
+ if (openId != null && gone.includes(openId)) setIdParam(null);
122
+ reload();
123
+ });
124
+ useEffect(() => { setNote(null); }, [filters]);
125
+
126
+ return (
127
+ <ScreenLayout
128
+ className={props.className}
129
+ title="Companies"
130
+ description="Every account, who works there and what has happened with them."
131
+ icon={<Building2 className="size-5" aria-hidden />}
132
+ actions={
133
+ <>
134
+ {props.toolbarActions}
135
+ <Button variant="outline" size="sm" onClick={() => setSheet("dupes")}><GitMerge className="size-4" aria-hidden />Duplicates{pending ? ` (${pending})` : ""}</Button>
136
+ <Button size="sm" onClick={() => setSheet("new")}><Plus className="size-4" aria-hidden />New company</Button>
137
+ </>
138
+ }
139
+ stats={[
140
+ { label: "Companies", value: list.loading && list.total === 0 ? "-" : list.total.toLocaleString(), hint: hasFilters ? "Matching the filters" : "In your workspace" },
141
+ { label: "Possible duplicates", value: pending ?? "-", tone: pending ? "warning" : "neutral", hint: "Review and merge" },
142
+ { label: "Industries on this page", value: industries, hint: "Distinct industries in view" },
143
+ ]}
144
+ >
145
+ <SplitView
146
+ detailOpen={openId != null}
147
+ expanded={full}
148
+ onExpandedChange={(e) => setView(e ? "full" : null)}
149
+ onCloseDetail={() => setIdParam(null)}
150
+ detailWidth="lg"
151
+ list={
152
+ <div className="flex flex-col gap-3">
153
+ <Toolbar
154
+ search={{ value: draft.name, onChange: (v) => set("name", v), placeholder: "Search companies by name" }}
155
+ filters={
156
+ <>
157
+ <input aria-label="Filter by domain" placeholder="Domain" className="h-9 w-36 rounded-md border bg-background px-3 text-sm" value={draft.domain} onChange={(e) => set("domain", e.target.value)} />
158
+ <input aria-label="Filter by industry" placeholder="Industry" className="h-9 w-36 rounded-md border bg-background px-3 text-sm" value={draft.industry} onChange={(e) => set("industry", e.target.value)} />
159
+ <input aria-label="Filter by country" placeholder="Country" className="h-9 w-32 rounded-md border bg-background px-3 text-sm" value={draft.country} onChange={(e) => set("country", e.target.value)} />
160
+ {hasFilters ? <Button variant="ghost" size="sm" onClick={() => setDraft(NO_FILTERS)}>Clear filters</Button> : null}
161
+ </>
162
+ }
163
+ />
164
+ {selection.ids.length > 0 ? (
165
+ <div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm" role="status">
166
+ <span className="tabular-nums">{selection.ids.length} selected</span>
167
+ {props.renderBulkActions?.({ ids: selection.ids, rows: selection.rows, clear: selection.clear })}
168
+ <Button variant="destructive" size="sm" onClick={() => setConfirmDelete(true)}><Trash2 className="size-4" aria-hidden />Delete {selection.ids.length}</Button>
169
+ <Button variant="ghost" size="sm" onClick={selection.clear}>Clear selection</Button>
170
+ </div>
171
+ ) : null}
172
+ {list.error ? <ApiError error={list.error} title="Could not load companies" onRetry={() => void list.refetch()} /> : null}
173
+ {note ? <p role="alert" className="text-sm text-destructive">{note}</p> : null}
174
+
175
+ <div className="overflow-hidden rounded-md border">
176
+ <Table>
177
+ <TableHeader>
178
+ <TableRow>
179
+ <TableHead className="w-8"><input type="checkbox" aria-label="Select page" checked={allOnPage} onChange={() => selection.togglePage(rows)} /></TableHead>
180
+ <SortHead label="Company" sortKey="name" sort={sort} onSort={toggleSort} />
181
+ <SortHead label="Domain" sortKey="domain" sort={sort} onSort={toggleSort} />
182
+ <SortHead label="Industry" sortKey="industry" sort={sort} onSort={toggleSort} />
183
+ <SortHead label="Employees" sortKey="employees" sort={sort} onSort={toggleSort} />
184
+ <SortHead label="Location" sortKey="location" sort={sort} onSort={toggleSort} />
185
+ {props.renderRowActions ? <TableHead className="w-8"><span className="sr-only">Actions</span></TableHead> : null}
186
+ </TableRow>
187
+ </TableHeader>
188
+ <TableBody>
189
+ {list.loading && rows.length === 0
190
+ ? Array.from({ length: 6 }, (_, i) => <TableRow key={`sk${i}`}><TableCell colSpan={6 + (props.renderRowActions ? 1 : 0)}><Skeleton className="h-6 w-full" /></TableCell></TableRow>)
191
+ : rows.map((row) => {
192
+ const name = companyName(row);
193
+ return (
194
+ <TableRow key={row.id} data-testid={`company-row-${row.id}`} data-state={row.id === openId ? "selected" : undefined} className="cursor-pointer" {...rowActivationProps(() => setIdParam(String(row.id)))}>
195
+ <TableCell onClick={(e) => e.stopPropagation()}>
196
+ <input type="checkbox" aria-label={`Select ${name}`} checked={selection.selected.has(row.id)} onChange={() => selection.toggle(row)} />
197
+ </TableCell>
198
+ <TableCell><span className="flex items-center gap-2 font-medium"><EntityAvatar name={name} imageUrl={row.logo_url} kind="company" size="sm" /><RecordLink param="id" id={row.id} onOpen={() => setIdParam(String(row.id))}>{name}</RecordLink></span></TableCell>
199
+ <TableCell>{row.domain || dash}</TableCell>
200
+ <TableCell>{row.industry || dash}</TableCell>
201
+ <TableCell>{row.employee_count || dash}</TableCell>
202
+ <TableCell>{location(row) || dash}</TableCell>
203
+ {props.renderRowActions ? <TableCell onClick={(e) => e.stopPropagation()}>{props.renderRowActions(row, { reload })}</TableCell> : null}
204
+ </TableRow>
205
+ );
206
+ })}
207
+ </TableBody>
208
+ </Table>
209
+ {!list.loading && !list.error && rows.length === 0 ? (
210
+ <EmptyState
211
+ title={hasFilters ? "No companies match these filters" : "No companies yet"}
212
+ description={hasFilters ? "Try fewer filters." : "Add a company, or import contacts and their companies are created from their domains."}
213
+ action={hasFilters ? <Button variant="outline" onClick={() => setDraft(NO_FILTERS)}>Clear filters</Button> : <Button onClick={() => setSheet("new")}>New company</Button>}
214
+ />
215
+ ) : null}
216
+ <Pager page={list.page} pageCount={list.pageCount} total={list.total} noun="companies" hasNext={list.hasNext} loading={list.loading} onPage={list.setPage} />
217
+ </div>
218
+ {sort ? <p className="text-xs text-muted-foreground">Sorted within this page only.</p> : null}
219
+ </div>
220
+ }
221
+ detail={openId != null ? (
222
+ <CompanyPanel key={openId} full={full} setFull={(v) => setView(v ? "full" : null)} companyId={openId} seed={list.rows.find((r) => r.id === openId)} onClose={() => setIdParam(null)} onChanged={reload} onDeleted={() => { setIdParam(null); reload(); }}
223
+ onOpenContact={props.onOpenContact} renderActions={props.renderDetailActions} extraTabs={props.extraDetailTabs} />
224
+ ) : null}
225
+ />
226
+
227
+ <FormSheet open={sheet === "new"} onOpenChange={(o) => setSheet(o ? "new" : null)} title="New company" description="A domain identifies the company; if it exists, it is updated instead.">
228
+ <RecordForm entity="company" onCancel={() => setSheet(null)} onSaved={({ id }) => { setSheet(null); reload(); if (id != null) setIdParam(String(id)); }} />
229
+ </FormSheet>
230
+ <FormSheet open={sheet === "dupes"} onOpenChange={(o) => setSheet(o ? "dupes" : null)} title="Duplicate companies" description="Merge pairs, or mark them as not duplicates.">
231
+ <DedupeReview entity="company" onChanged={reload} />
232
+ </FormSheet>
233
+
234
+ <ConfirmSpendDialog
235
+ open={confirmDelete}
236
+ tier="destructive"
237
+ title={`Delete ${plural(selection.ids.length, "company", "companies")}`}
238
+ description="Their contacts stay but lose the company link."
239
+ affected={selection.ids.length}
240
+ confirming={bulk.loading}
241
+ onConfirm={() => void bulk.mutate(selection.ids)}
242
+ onCancel={() => setConfirmDelete(false)}
243
+ />
244
+ </ScreenLayout>
245
+ );
246
+ }
247
+
248
+ interface CompanyPanelProps {
249
+ full: boolean;
250
+ setFull: (full: boolean) => void;
251
+ companyId: number;
252
+ /** The list row, for fields the detail answer lacks (owner). */
253
+ seed?: CompanyRecord;
254
+ onClose: () => void;
255
+ onChanged: () => void;
256
+ onDeleted: () => void;
257
+ onOpenContact?: (contact: ContactRecord) => void;
258
+ renderActions?: CompaniesScreenProps["renderDetailActions"];
259
+ extraTabs?: CompaniesScreenProps["extraDetailTabs"];
260
+ }
261
+
262
+ function CompanyPanel({ full, setFull, companyId, seed, onClose, onChanged, onDeleted, onOpenContact, renderActions, extraTabs = [] }: CompanyPanelProps) {
263
+ const [editing, setEditing] = useState(false);
264
+ const [confirm, setConfirm] = useState(false);
265
+ const [feedKey, setFeedKey] = useState(0);
266
+ const [addingPerson, setAddingPerson] = useState(false);
267
+ const [peopleKey, setPeopleKey] = useState(0);
268
+ const query = useG8Query((signal) => g8Get<CompanyRecord>(`/companies/${companyId}`, { include_custom_fields: true }, { signal }), [companyId], {
269
+ key: ["companies", "detail", companyId],
270
+ // The row the company was opened from paints the panel at once; the full record replaces it.
271
+ placeholderData: seed ? { data: seed } : undefined,
272
+ });
273
+ /** Edit, delete and slot actions wait for the full record: a list row must never seed the edit form. */
274
+ const partial = query.isPlaceholderData === true;
275
+ const remove = useG8Mutation(async () => {
276
+ try { await g8Delete(`/companies/${companyId}`); } catch (e) { if (errorStatus(e) !== 404) throw e; }
277
+ }, { invalidates: [["companies", "list"]], onSuccess: () => { setConfirm(false); onDeleted(); } });
278
+ const bump = () => setFeedKey((k) => k + 1);
279
+
280
+ if (query.loading && !query.data) return <div className="flex flex-col gap-3 p-4"><Skeleton className="h-14 w-full" /><Skeleton className="h-40 w-full" /></div>;
281
+ // A failed full record must not leave the list row on screen with every action disabled.
282
+ if (query.error && (!query.data || partial)) return <div className="p-4"><ApiError error={query.error} title="Could not load this company" onRetry={() => void query.refetch()} /></div>;
283
+ const company = query.data!.data;
284
+ const name = companyName(company);
285
+ const val = (v?: string | number | null): ReactNode => (v == null || v === "" ? dash : v);
286
+
287
+ return (
288
+ <>
289
+ <DetailPanel
290
+ title={name}
291
+ subtitle={[company.domain, company.industry].filter(Boolean).join(" - ") || "No domain or industry"}
292
+ avatar={<EntityAvatar name={name} imageUrl={company.logo_url} kind="company" size="lg" />}
293
+ onClose={onClose}
294
+ onExpand={full ? undefined : () => setFull(true)}
295
+ onCollapse={full ? () => setFull(false) : undefined}
296
+ actions={
297
+ <>
298
+ {partial ? null : renderActions?.(company, { reload: () => { void query.refetch(); bump(); onChanged(); } })}
299
+ <Button variant="outline" size="sm" disabled={partial} title={partial ? "Loading the full record" : undefined} onClick={() => setEditing(true)}>Edit</Button>
300
+ <Button variant="destructive" size="sm" disabled={partial} onClick={() => setConfirm(true)}>Delete</Button>
301
+ </>
302
+ }
303
+ tabs={[
304
+ { id: "overview", label: "Overview", content: (
305
+ <KeyValueList columns={2} items={[
306
+ { label: "Website", value: val(company.website) },
307
+ { label: "Phone", value: val(company.phone) },
308
+ { label: "Employees", value: val(company.employee_count) },
309
+ { label: "Revenue", value: val(company.revenue) },
310
+ { label: "Founded", value: val(company.founded_year) },
311
+ { label: "Location", value: val(location(company)) },
312
+ { label: "LinkedIn", value: val(company.linkedin_url) },
313
+ { label: "Owner", value: val(company.owner_name ?? seed?.owner_name) },
314
+ { label: "People", value: val(company.contact_count) },
315
+ { label: "Added", value: val(formatDay(company.created_at)) },
316
+ ...Object.entries(company.custom_fields ?? {}).map(([k, v]) => ({ label: k, value: val(v) })),
317
+ ...(company.description ? [{ label: "About", value: company.description }] : []),
318
+ ]} />
319
+ ) },
320
+ { id: "people", label: "People", content: (
321
+ <div className="flex flex-col gap-3">
322
+ <div><Button variant="outline" size="sm" onClick={() => setAddingPerson(true)}><UserPlus className="size-4" aria-hidden />Add person</Button></div>
323
+ <ContactTable key={peopleKey} companyId={companyId} pageSize={10} onOpen={onOpenContact} />
324
+ </div>
325
+ ) },
326
+ { id: "activity", label: "Activity", content: <ActivityTimeline entityType="company" entityId={companyId} refreshKey={feedKey} /> },
327
+ { id: "notes", label: "Notes", content: <NotesPanel entity="company" entityId={companyId} onChanged={bump} /> },
328
+ { id: "tasks", label: "Tasks", content: <TaskList entity="company" entityId={companyId} onChanged={bump} /> },
329
+ ...(partial ? [] : extraTabs).map((t) => ({ id: t.id, label: t.label, content: t.render(company) })),
330
+ ]}
331
+ />
332
+ {remove.error ? <div className="p-4"><ApiError error={remove.error} title="Could not delete the company" /></div> : null}
333
+ <FormSheet open={editing && !partial} onOpenChange={setEditing} title={`Edit ${name}`}>
334
+ <RecordForm entity="company" record={company} onCancel={() => setEditing(false)} onSaved={() => { setEditing(false); void query.refetch(); bump(); onChanged(); }} />
335
+ </FormSheet>
336
+ <FormSheet open={addingPerson} onOpenChange={setAddingPerson} title={`Add a person at ${name}`} description="They are linked to this company by its domain.">
337
+ <RecordForm entity="contact" defaults={company.domain ? { company_domain: company.domain } : undefined} onCancel={() => setAddingPerson(false)}
338
+ onSaved={() => { setAddingPerson(false); setPeopleKey((k) => k + 1); bump(); onChanged(); void query.refetch(); }}
339
+ onDuplicate={() => setAddingPerson(false)} />
340
+ </FormSheet>
341
+ <ConfirmSpendDialog open={confirm} tier="destructive" title={`Delete ${name}`} description="Its contacts stay but lose the company link." affected={1}
342
+ confirming={remove.loading} onConfirm={() => void remove.mutate(undefined)} onCancel={() => setConfirm(false)} />
343
+ </>
344
+ );
345
+ }
@@ -21,7 +21,7 @@ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
21
21
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
22
22
  import { useG8Query } from "@/g8/hooks/use-g8-query";
23
23
  import { g8Delete, g8Get } from "@/g8/lib/g8-client";
24
- import { initials, location } from "../lib/crm-types";
24
+ import { companyName, initials, location } from "../lib/crm-types";
25
25
  import type { CompanyRecord, ContactRecord } from "../lib/crm-types";
26
26
  import { ActivityTimeline } from "./activity-timeline";
27
27
  import { ContactTable } from "./contact-table";
@@ -52,14 +52,16 @@ export function CompanyDetail({ companyId, onDeleted, onOpenContact, renderActio
52
52
  const [editing, setEditing] = useState(false);
53
53
  const [confirmDelete, setConfirmDelete] = useState(false);
54
54
  const [feedKey, setFeedKey] = useState(0);
55
- const query = useG8Query((signal) => g8Get<CompanyRecord>(`/companies/${companyId}`, { include_custom_fields: true }, { signal }), [companyId]);
56
- const remove = useG8Mutation(() => g8Delete(`/companies/${companyId}`), { onSuccess: () => { setConfirmDelete(false); onDeleted?.(companyId); } });
55
+ const query = useG8Query((signal) => g8Get<CompanyRecord>(`/companies/${companyId}`, { include_custom_fields: true }, { signal }), [companyId], { key: ["companies", "detail", companyId] });
56
+ const remove = useG8Mutation(() => g8Delete(`/companies/${companyId}`), { invalidates: [["companies", "list"]], onSuccess: () => { setConfirmDelete(false); onDeleted?.(companyId); } });
57
57
  const bump = () => setFeedKey((k) => k + 1);
58
58
 
59
- if (query.loading && !query.data) return <div className={`flex flex-col gap-3 ${className ?? ""}`}><Skeleton className="h-16 w-full" /><Skeleton className="h-40 w-full" /></div>;
60
- if (query.error && !query.data) return <ApiError error={query.error} title="Could not load this company" onRetry={() => void query.refetch()} />;
59
+ // The hook keeps the previous record's data while the next one loads: show THIS record only (edit, delete and tabs all act on companyId).
60
+ const wrongRecord = query.data != null && String(query.data.data?.id) !== String(companyId);
61
+ if (query.error && (!query.data || wrongRecord)) return <ApiError error={query.error} title="Could not load this company" onRetry={() => void query.refetch()} />;
62
+ if ((query.loading && !query.data) || wrongRecord) return <div className={`flex flex-col gap-3 ${className ?? ""}`}><Skeleton className="h-16 w-full" /><Skeleton className="h-40 w-full" /></div>;
61
63
  const company = query.data!.data;
62
- const name = company.name || company.domain || "Unnamed company";
64
+ const name = companyName(company);
63
65
 
64
66
  return (
65
67
  <div className={`flex flex-col gap-4 ${className ?? ""}`}>
@@ -17,11 +17,14 @@ import { ApiError } from "@/g8/components/api-error";
17
17
  import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
18
18
  import { EmptyState } from "@/g8/components/empty-state";
19
19
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
20
+ import { rowActivationProps } from "@/g8/hooks/use-row-activation";
21
+ import { RecordLink } from "@/g8/components/screen/record-link";
22
+ import { plural } from "@/g8/lib/plural";
20
23
  import { g8Delete, g8Get } from "@/g8/lib/g8-client";
21
24
  import { useCustomFields } from "../hooks/use-custom-fields";
22
25
  import { useDebouncedValue } from "../hooks/use-debounced";
23
26
  import { usePageQuery } from "../hooks/use-page-query";
24
- import { errorStatus, fieldLabel, location } from "../lib/crm-types";
27
+ import { companyName, errorStatus, fieldLabel, location } from "../lib/crm-types";
25
28
  import type { CompanyRecord } from "../lib/crm-types";
26
29
 
27
30
  export interface CompanyFilters {
@@ -32,6 +35,8 @@ export interface CompanyFilters {
32
35
  }
33
36
 
34
37
  export interface CompanyTableProps {
38
+ /** URL param the host screen reads for this record type. With it the name is a real link (Cmd-click opens it in a new tab); without it a plain button. */
39
+ linkParam?: string;
35
40
  pageSize?: number;
36
41
  initialFilters?: CompanyFilters;
37
42
  selectable?: boolean;
@@ -57,6 +62,7 @@ export function CompanyTable(props: CompanyTableProps) {
57
62
  (page, signal) => g8Get<CompanyRecord[]>("/companies", { page, limit: pageSize, include_custom_fields: customFields || undefined, ...filters }, { signal }),
58
63
  [filters, customFields, props.refreshKey],
59
64
  pageSize,
65
+ ["companies", "list", "table"],
60
66
  );
61
67
 
62
68
  const ids = useMemo(() => [...selected.keys()], [selected]);
@@ -89,6 +95,7 @@ export function CompanyTable(props: CompanyTableProps) {
89
95
  const gone = toDelete.filter((_, i) => results[i].status === "fulfilled" || errorStatus((results[i] as PromiseRejectedResult).reason) === 404);
90
96
  return { gone, total: toDelete.length };
91
97
  }, {
98
+ invalidates: [["companies", "list"]],
92
99
  onSuccess: ({ gone, total }) => {
93
100
  setConfirmOpen(false);
94
101
  const next = new Map(selected);
@@ -139,9 +146,9 @@ export function CompanyTable(props: CompanyTableProps) {
139
146
  {list.loading && list.rows.length === 0
140
147
  ? Array.from({ length: 5 }, (_, i) => <TableRow key={`sk${i}`}><TableCell colSpan={5 + columns.length + (selectable ? 1 : 0)}><Skeleton className="h-5 w-full" /></TableCell></TableRow>)
141
148
  : list.rows.map((row) => (
142
- <TableRow key={row.id} data-testid={`company-row-${row.id}`} className={onOpen ? "cursor-pointer" : undefined} onClick={() => onOpen?.(row)}>
149
+ <TableRow key={row.id} data-testid={`company-row-${row.id}`} className={onOpen ? "cursor-pointer" : undefined} {...(onOpen ? rowActivationProps(() => onOpen(row)) : {})}>
143
150
  {selectable ? <TableCell onClick={(e) => e.stopPropagation()}><input type="checkbox" aria-label={`Select ${row.name ?? row.domain}`} checked={selected.has(row.id)} onChange={() => toggle(row)} /></TableCell> : null}
144
- <TableCell className="font-medium">{row.name || row.domain || "Unnamed company"}</TableCell>
151
+ <TableCell className="font-medium">{onOpen ? <RecordLink param={props.linkParam} id={row.id} onOpen={() => onOpen(row)}>{companyName(row)}</RecordLink> : companyName(row)}</TableCell>
145
152
  <TableCell>{row.domain || "-"}</TableCell>
146
153
  <TableCell>{row.industry || "-"}</TableCell>
147
154
  <TableCell>{row.employee_count || "-"}</TableCell>
@@ -165,7 +172,7 @@ export function CompanyTable(props: CompanyTableProps) {
165
172
  </div>
166
173
  </div>
167
174
 
168
- <ConfirmSpendDialog open={confirmOpen} tier="destructive" title={`Delete ${ids.length} compan${ids.length === 1 ? "y" : "ies"}`}
175
+ <ConfirmSpendDialog open={confirmOpen} tier="destructive" title={`Delete ${plural(ids.length, "company", "companies")}`}
169
176
  description="Their contacts stay, but lose the company link." affected={ids.length} confirming={remove.loading}
170
177
  onConfirm={() => void remove.mutate(ids)} onCancel={() => setConfirmOpen(false)} />
171
178
  </div>
@@ -58,14 +58,17 @@ export function ContactDetail({ contactId, onDeleted, onOpenCompany, renderActio
58
58
  const [editing, setEditing] = useState(false);
59
59
  const [confirmDelete, setConfirmDelete] = useState(false);
60
60
  const [feedKey, setFeedKey] = useState(0);
61
- const query = useG8Query((signal) => g8Get<ContactRecord>(`/contacts/${contactId}`, { include_custom_fields: true }, { signal }), [contactId]);
61
+ const query = useG8Query((signal) => g8Get<ContactRecord>(`/contacts/${contactId}`, { include_custom_fields: true }, { signal }), [contactId], { key: ["contacts", "detail", contactId] });
62
62
  const remove = useG8Mutation(() => g8Delete(`/contacts/${contactId}`), {
63
+ invalidates: [["contacts", "list"]],
63
64
  onSuccess: () => { setConfirmDelete(false); onDeleted?.(contactId); },
64
65
  });
65
66
  const bump = () => setFeedKey((k) => k + 1);
66
67
 
67
- if (query.loading && !query.data) return <div className={`flex flex-col gap-3 ${className ?? ""}`}><Skeleton className="h-16 w-full" /><Skeleton className="h-40 w-full" /></div>;
68
- if (query.error && !query.data) return <ApiError error={query.error} title="Could not load this contact" onRetry={() => void query.refetch()} />;
68
+ // The hook keeps the previous record's data while the next one loads: show THIS record only (edit, delete and tabs all act on contactId).
69
+ const wrongRecord = query.data != null && String(query.data.data?.id) !== String(contactId);
70
+ if (query.error && (!query.data || wrongRecord)) return <ApiError error={query.error} title="Could not load this contact" onRetry={() => void query.refetch()} />;
71
+ if ((query.loading && !query.data) || wrongRecord) return <div className={`flex flex-col gap-3 ${className ?? ""}`}><Skeleton className="h-16 w-full" /><Skeleton className="h-40 w-full" /></div>;
69
72
  const contact = query.data!.data;
70
73
  const name = contactName(contact);
71
74
 
@@ -20,6 +20,9 @@ import { ApiError } from "@/g8/components/api-error";
20
20
  import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
21
21
  import { EmptyState } from "@/g8/components/empty-state";
22
22
  import { g8Delete, g8Get } from "@/g8/lib/g8-client";
23
+ import { rowActivationProps } from "@/g8/hooks/use-row-activation";
24
+ import { RecordLink } from "@/g8/components/screen/record-link";
25
+ import { plural } from "@/g8/lib/plural";
23
26
  import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
24
27
  import { useCustomFields } from "../hooks/use-custom-fields";
25
28
  import { useDebouncedValue } from "../hooks/use-debounced";
@@ -35,6 +38,8 @@ export interface ContactFilters {
35
38
  }
36
39
 
37
40
  export interface ContactTableProps {
41
+ /** URL param the host screen reads for this record type. With it the name is a real link (Cmd-click opens it in a new tab); without it a plain button. */
42
+ linkParam?: string;
38
43
  /** Only members of this list. */
39
44
  listId?: number;
40
45
  /** Only people of this company (mashup company id). */
@@ -82,6 +87,7 @@ export function ContactTable(props: ContactTableProps) {
82
87
  ),
83
88
  [listId, companyId, filters, customFields, props.refreshKey],
84
89
  pageSize,
90
+ ["contacts", "list", "table"],
85
91
  );
86
92
 
87
93
  const ids = useMemo(() => [...selected.keys()], [selected]);
@@ -117,6 +123,7 @@ export function ContactTable(props: ContactTableProps) {
117
123
  const gone = toDelete.filter((_, i) => results[i].status === "fulfilled" || errorStatus((results[i] as PromiseRejectedResult).reason) === 404);
118
124
  return { gone, total: toDelete.length };
119
125
  }, {
126
+ invalidates: [["contacts", "list"]],
120
127
  onSuccess: ({ gone, total }) => {
121
128
  setConfirmOpen(false);
122
129
  const next = new Map(selected);
@@ -172,13 +179,13 @@ export function ContactTable(props: ContactTableProps) {
172
179
  <TableRow key={`sk${i}`}><TableCell colSpan={5 + columns.length + (selectable ? 1 : 0)}><Skeleton className="h-5 w-full" /></TableCell></TableRow>
173
180
  ))
174
181
  : list.rows.map((row) => (
175
- <TableRow key={row.id} data-testid={`contact-row-${row.id}`} className={onOpen ? "cursor-pointer" : undefined} onClick={() => onOpen?.(row)}>
182
+ <TableRow key={row.id} data-testid={`contact-row-${row.id}`} className={onOpen ? "cursor-pointer" : undefined} {...(onOpen ? rowActivationProps(() => onOpen(row)) : {})}>
176
183
  {selectable ? (
177
184
  <TableCell onClick={(e) => e.stopPropagation()}>
178
185
  <input type="checkbox" aria-label={`Select ${contactName(row)}`} checked={selected.has(row.id)} onChange={() => toggle(row)} />
179
186
  </TableCell>
180
187
  ) : null}
181
- <TableCell className="font-medium">{contactName(row)}</TableCell>
188
+ <TableCell className="font-medium">{onOpen ? <RecordLink param={props.linkParam} id={row.id} onOpen={() => onOpen(row)}>{contactName(row)}</RecordLink> : contactName(row)}</TableCell>
182
189
  <TableCell><CellValue value={row.job_title} /></TableCell>
183
190
  <TableCell><CellValue value={row.company_name} /></TableCell>
184
191
  <TableCell><CellValue value={row.work_email} /></TableCell>
@@ -205,7 +212,7 @@ export function ContactTable(props: ContactTableProps) {
205
212
  <ConfirmSpendDialog
206
213
  open={confirmOpen}
207
214
  tier="destructive"
208
- title={`Delete ${ids.length} contact${ids.length === 1 ? "" : "s"}`}
215
+ title={`Delete ${plural(ids.length, "contact")}`}
209
216
  description="They are removed from every list and sequence."
210
217
  affected={ids.length}
211
218
  confirming={remove.loading}