@graph8/sdk 0.373.0 → 0.374.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/README.md +3 -3
  2. package/blocks/_foundation/README.md +47 -0
  3. package/blocks/_foundation/block.json +13 -0
  4. package/blocks/_foundation/components/auth-gate.tsx +363 -0
  5. package/blocks/_foundation/server/auth.ts +815 -0
  6. package/blocks/campaigns/README.md +1 -1
  7. package/blocks/campaigns/block.json +34 -1
  8. package/blocks/campaigns/components/campaign-links.tsx +180 -0
  9. package/blocks/company-intelligence/components/research-run.tsx +6 -2
  10. package/blocks/crm/README.md +19 -0
  11. package/blocks/crm/block.json +205 -6
  12. package/blocks/crm/components/bulk-update.tsx +119 -0
  13. package/blocks/crm/components/contact-documents-tab.tsx +144 -0
  14. package/blocks/crm/components/contact-insights-tab.tsx +118 -0
  15. package/blocks/crm/components/contact-lookup-link.tsx +47 -6
  16. package/blocks/crm/components/contact-row-actions.tsx +43 -0
  17. package/blocks/crm/components/contact-tag-chips.tsx +113 -0
  18. package/blocks/crm/components/contacts-screen.tsx +46 -5
  19. package/blocks/crm/components/owner-assign.tsx +132 -0
  20. package/blocks/crm/components/record-open-links.tsx +79 -0
  21. package/blocks/crm/components/tag-assign.tsx +112 -0
  22. package/blocks/crm/components/tag-manager.tsx +178 -0
  23. package/blocks/crm/components/unlock-info-button.tsx +82 -0
  24. package/blocks/crm/components/verify-email-button.tsx +93 -0
  25. package/blocks/crm/hooks/use-contact-tags.ts +29 -0
  26. package/blocks/crm/lib/record-actions.ts +102 -0
  27. package/blocks/deals/README.md +1 -0
  28. package/blocks/deals/block.json +17 -0
  29. package/blocks/deals/components/create-deal-button.tsx +109 -0
  30. package/blocks/deals/components/deal-detail.tsx +5 -3
  31. package/blocks/dialer/components/call-log-screen.tsx +6 -1
  32. package/blocks/documents/README.md +86 -0
  33. package/blocks/documents/block.json +378 -0
  34. package/blocks/documents/components/account-bar.tsx +48 -0
  35. package/blocks/documents/components/correspondence-form.tsx +95 -0
  36. package/blocks/documents/components/correspondence-import.tsx +106 -0
  37. package/blocks/documents/components/correspondence-panel.tsx +191 -0
  38. package/blocks/documents/components/documents-screen.tsx +76 -0
  39. package/blocks/documents/components/library-document-detail.tsx +175 -0
  40. package/blocks/documents/components/library-document-form.tsx +105 -0
  41. package/blocks/documents/components/library-folder-form.tsx +74 -0
  42. package/blocks/documents/components/library-panel.tsx +235 -0
  43. package/blocks/documents/components/library-upload.tsx +160 -0
  44. package/blocks/documents/components/question-row.tsx +130 -0
  45. package/blocks/documents/components/questionnaire-detail.tsx +197 -0
  46. package/blocks/documents/components/questionnaires-panel.tsx +173 -0
  47. package/blocks/documents/components/record-documents-tab.tsx +109 -0
  48. package/blocks/documents/lib/documents-types.ts +252 -0
  49. package/blocks/documents/lib/download.ts +64 -0
  50. package/blocks/documents/recipes/follow-a-questionnaire.ts +26 -0
  51. package/blocks/documents/recipes/log-account-correspondence.ts +27 -0
  52. package/blocks/documents/recipes/organize-the-library.ts +25 -0
  53. package/blocks/email-signatures/README.md +123 -0
  54. package/blocks/email-signatures/block.json +341 -0
  55. package/blocks/email-signatures/components/email-signatures-screen.tsx +166 -0
  56. package/blocks/email-signatures/components/signature-assign-template.tsx +119 -0
  57. package/blocks/email-signatures/components/signature-block-editor.tsx +87 -0
  58. package/blocks/email-signatures/components/signature-campaign-form.tsx +165 -0
  59. package/blocks/email-signatures/components/signature-campaigns.tsx +75 -0
  60. package/blocks/email-signatures/components/signature-identity-form.tsx +131 -0
  61. package/blocks/email-signatures/components/signature-mailboxes.tsx +195 -0
  62. package/blocks/email-signatures/components/signature-policy.tsx +130 -0
  63. package/blocks/email-signatures/components/signature-preview.tsx +119 -0
  64. package/blocks/email-signatures/components/signature-report.tsx +77 -0
  65. package/blocks/email-signatures/components/signature-template-details.tsx +112 -0
  66. package/blocks/email-signatures/components/signature-template-form.tsx +109 -0
  67. package/blocks/email-signatures/hooks/use-signatures-data.ts +93 -0
  68. package/blocks/email-signatures/lib/signatures-types.ts +392 -0
  69. package/blocks/email-signatures/recipes/assign-a-signature-to-a-new-hire.ts +32 -0
  70. package/blocks/email-signatures/recipes/create-a-template-and-make-it-default.ts +35 -0
  71. package/blocks/email-signatures/recipes/signature-engagement-report.ts +16 -0
  72. package/blocks/enrichment/README.md +22 -4
  73. package/blocks/enrichment/block.json +145 -5
  74. package/blocks/enrichment/components/auto-enrichment-configs.tsx +134 -0
  75. package/blocks/enrichment/components/auto-enrichment-settings.tsx +82 -0
  76. package/blocks/enrichment/components/bulk-enrich-panel.tsx +7 -7
  77. package/blocks/enrichment/components/enrichment-activity.tsx +93 -0
  78. package/blocks/enrichment/components/enrichment-job-detail.tsx +18 -0
  79. package/blocks/enrichment/components/enrichment-screen.tsx +34 -3
  80. package/blocks/enrichment/components/list-pipelines.tsx +97 -0
  81. package/blocks/enrichment/components/pipeline-detail.tsx +214 -0
  82. package/blocks/enrichment/components/pipeline-editor.tsx +144 -0
  83. package/blocks/enrichment/components/pipeline-templates.tsx +90 -0
  84. package/blocks/enrichment/lib/enrichment-types.ts +12 -0
  85. package/blocks/enrichment/lib/pipeline-types.ts +234 -0
  86. package/blocks/forms/README.md +2 -0
  87. package/blocks/forms/components/forms-screen.tsx +6 -3
  88. package/blocks/inbox/README.md +40 -4
  89. package/blocks/inbox/block.json +130 -8
  90. package/blocks/inbox/components/blocked-senders.tsx +103 -0
  91. package/blocks/inbox/components/compose-message.tsx +193 -0
  92. package/blocks/inbox/components/inbox-screen.tsx +54 -6
  93. package/blocks/inbox/components/inbox-search.tsx +106 -0
  94. package/blocks/inbox/components/tag-manager.tsx +136 -0
  95. package/blocks/inbox/components/thread-actions.tsx +133 -0
  96. package/blocks/inbox/components/thread-view.tsx +2 -0
  97. package/blocks/inbox/hooks/use-inbox-mailboxes.ts +31 -0
  98. package/blocks/inbox/lib/inbox-manage.ts +227 -0
  99. package/blocks/newsletter/components/newsletter-screen.tsx +3 -1
  100. package/blocks/pages/block.json +10 -1
  101. package/blocks/pages/hooks/use-landing-page-paths.ts +60 -0
  102. package/blocks/programmatic-pages/README.md +82 -0
  103. package/blocks/programmatic-pages/block.json +292 -0
  104. package/blocks/programmatic-pages/components/programmatic-campaign-form.tsx +136 -0
  105. package/blocks/programmatic-pages/components/programmatic-generate.tsx +112 -0
  106. package/blocks/programmatic-pages/components/programmatic-generated-pages.tsx +210 -0
  107. package/blocks/programmatic-pages/components/programmatic-overview.tsx +84 -0
  108. package/blocks/programmatic-pages/components/programmatic-pages-screen.tsx +221 -0
  109. package/blocks/programmatic-pages/components/programmatic-preview.tsx +66 -0
  110. package/blocks/programmatic-pages/components/programmatic-publish.tsx +102 -0
  111. package/blocks/programmatic-pages/components/programmatic-settings.tsx +96 -0
  112. package/blocks/programmatic-pages/components/programmatic-source-fields.tsx +75 -0
  113. package/blocks/programmatic-pages/components/programmatic-source.tsx +106 -0
  114. package/blocks/programmatic-pages/components/programmatic-variables.tsx +84 -0
  115. package/blocks/programmatic-pages/hooks/use-programmatic-workspace.ts +19 -0
  116. package/blocks/programmatic-pages/lib/programmatic-types.ts +330 -0
  117. package/blocks/programmatic-pages/recipes/create-a-programmatic-campaign.ts +23 -0
  118. package/blocks/programmatic-pages/recipes/render-and-follow.ts +21 -0
  119. package/blocks/programmatic-pages/recipes/review-then-publish.ts +17 -0
  120. package/blocks/quotes/README.md +25 -1
  121. package/blocks/quotes/block.json +123 -6
  122. package/blocks/quotes/components/manual-products.tsx +144 -0
  123. package/blocks/quotes/components/quote-batch-actions.tsx +86 -0
  124. package/blocks/quotes/components/quote-documents.tsx +94 -0
  125. package/blocks/quotes/components/quote-evidence.tsx +95 -0
  126. package/blocks/quotes/components/quote-list.tsx +23 -5
  127. package/blocks/quotes/components/quote-settings.tsx +184 -0
  128. package/blocks/quotes/components/quote-templates.tsx +189 -0
  129. package/blocks/quotes/components/quote-viewer.tsx +16 -3
  130. package/blocks/quotes/components/quotes-screen.tsx +15 -5
  131. package/blocks/quotes/lib/quote-admin.ts +264 -0
  132. package/blocks/sequencer/README.md +55 -0
  133. package/blocks/sequencer/block.json +172 -5
  134. package/blocks/sequencer/components/enrollment-withdraw.tsx +71 -0
  135. package/blocks/sequencer/components/sequence-builder.tsx +13 -3
  136. package/blocks/sequencer/components/sequence-campaign-link.tsx +78 -0
  137. package/blocks/sequencer/components/sequence-detail.tsx +6 -0
  138. package/blocks/sequencer/components/sequence-edit-form.tsx +111 -0
  139. package/blocks/sequencer/components/sequence-enrollments.tsx +2 -1
  140. package/blocks/sequencer/components/sequence-manage-actions.tsx +119 -0
  141. package/blocks/sequencer/components/sequence-senders-panel.tsx +84 -0
  142. package/blocks/sequencer/components/sequence-templates.tsx +160 -0
  143. package/blocks/sequencer/components/sequences-screen.tsx +36 -6
  144. package/blocks/sequencer/components/step-ai-assist.tsx +196 -0
  145. package/blocks/sequencer/components/step-editor.tsx +10 -1
  146. package/blocks/sequencer/components/step-templates.tsx +224 -0
  147. package/blocks/sequencer/lib/sequencer-manage.ts +235 -0
  148. package/blocks/sequencer/lib/sequencer-utils.ts +4 -0
  149. package/blocks/signals/README.md +39 -0
  150. package/blocks/signals/block.json +287 -0
  151. package/blocks/signals/components/auto-seed-panel.tsx +111 -0
  152. package/blocks/signals/components/listener-form.tsx +184 -0
  153. package/blocks/signals/components/matched-posts.tsx +195 -0
  154. package/blocks/signals/components/post-engagements.tsx +114 -0
  155. package/blocks/signals/components/record-social-tab.tsx +49 -0
  156. package/blocks/signals/components/social-listeners-screen.tsx +250 -0
  157. package/blocks/signals/components/social-settings.tsx +128 -0
  158. package/blocks/signals/components/source-lists.tsx +83 -0
  159. package/blocks/signals/components/top-engagers.tsx +68 -0
  160. package/blocks/signals/components/x-integration.tsx +129 -0
  161. package/blocks/signals/lib/signals-types.ts +228 -0
  162. package/blocks/signals/recipes/preview-and-create-a-listener.ts +16 -0
  163. package/blocks/signals/recipes/spend-against-caps.ts +14 -0
  164. package/blocks/signals/recipes/triage-matched-posts.ts +18 -0
  165. package/blocks/visitors/components/traffic-chart.tsx +3 -2
  166. package/blocks/web-analytics/README.md +102 -0
  167. package/blocks/web-analytics/block.json +292 -0
  168. package/blocks/web-analytics/components/acquisition-tab.tsx +154 -0
  169. package/blocks/web-analytics/components/analytics-filters.tsx +161 -0
  170. package/blocks/web-analytics/components/audience-tab.tsx +135 -0
  171. package/blocks/web-analytics/components/conversions-tab.tsx +128 -0
  172. package/blocks/web-analytics/components/engagement-tab.tsx +123 -0
  173. package/blocks/web-analytics/components/overview-tab.tsx +102 -0
  174. package/blocks/web-analytics/components/pages-tab.tsx +148 -0
  175. package/blocks/web-analytics/components/ranked-table.tsx +103 -0
  176. package/blocks/web-analytics/components/realtime-tab.tsx +103 -0
  177. package/blocks/web-analytics/components/report-card.tsx +71 -0
  178. package/blocks/web-analytics/components/web-analytics-screen.tsx +90 -0
  179. package/blocks/web-analytics/hooks/use-report.ts +48 -0
  180. package/blocks/web-analytics/lib/analytics-scope.ts +155 -0
  181. package/blocks/web-analytics/lib/page-path.ts +14 -0
  182. package/blocks/web-analytics/lib/web-analytics-types.ts +187 -0
  183. package/blocks/web-analytics/recipes/best-landing-pages.ts +26 -0
  184. package/blocks/web-analytics/recipes/form-conversion-check.ts +19 -0
  185. package/blocks/web-analytics/recipes/weekly-traffic-report.ts +37 -0
  186. package/blocks/workbench/README.md +54 -2
  187. package/blocks/workbench/block.json +234 -5
  188. package/blocks/workbench/components/workbench-auto-enrich.tsx +88 -0
  189. package/blocks/workbench/components/workbench-automation.tsx +36 -0
  190. package/blocks/workbench/components/workbench-connections.tsx +136 -0
  191. package/blocks/workbench/components/workbench-crm-import.tsx +91 -0
  192. package/blocks/workbench/components/workbench-datafile-import.tsx +154 -0
  193. package/blocks/workbench/components/workbench-grid.tsx +70 -7
  194. package/blocks/workbench/components/workbench-pipelines.tsx +173 -0
  195. package/blocks/workbench/components/workbench-row-actions.tsx +8 -1
  196. package/blocks/workbench/components/workbench-schedule.tsx +191 -0
  197. package/blocks/workbench/components/workbench-screen.tsx +50 -17
  198. package/blocks/workbench/components/workbench-send-to-sequence.tsx +116 -0
  199. package/blocks/workbench/components/workbench-table-settings.tsx +53 -0
  200. package/blocks/workbench/components/workbench-triggers.tsx +178 -0
  201. package/blocks/workbench/hooks/use-connected-crms.ts +29 -0
  202. package/blocks/workbench/lib/api-status.ts +8 -0
  203. package/blocks/workbench/lib/automation-types.ts +257 -0
  204. package/dist/cli.js +257 -51
  205. package/dist/index.browser.js +1 -1
  206. package/dist/index.browser.js.map +1 -1
  207. package/dist/index.browser.mjs +1 -1
  208. package/dist/index.browser.mjs.map +1 -1
  209. package/dist/index.js +1 -1
  210. package/dist/index.js.map +1 -1
  211. package/dist/index.mjs +1 -1
  212. package/dist/index.mjs.map +1 -1
  213. package/dist/react.browser.js +1 -1
  214. package/dist/react.browser.js.map +1 -1
  215. package/dist/react.browser.mjs +1 -1
  216. package/dist/react.browser.mjs.map +1 -1
  217. package/dist/react.js +1 -1
  218. package/dist/react.js.map +1 -1
  219. package/dist/react.mjs +1 -1
  220. package/dist/react.mjs.map +1 -1
  221. package/dist/types/blocks/auth.d.ts +45 -0
  222. package/dist/types/blocks/create.d.ts +5 -2
  223. package/dist/types/version.d.ts +2 -2
  224. package/docs/api/forms.md +1 -1
  225. package/package.json +1 -1
  226. package/stacks/6sense/pages/contacts.tsx.tmpl +2 -0
  227. package/stacks/6sense.json +20 -0
  228. package/stacks/apollo/pages/calls.tsx.tmpl +9 -9
  229. package/stacks/apollo/pages/campaigns.tsx.tmpl +27 -0
  230. package/stacks/apollo/pages/contacts.tsx.tmpl +6 -0
  231. package/stacks/apollo/pages/inbox-contact.tsx.tmpl +3 -3
  232. package/stacks/apollo/pages/lists.tsx.tmpl +14 -2
  233. package/stacks/apollo/pages/sequences.tsx.tmpl +8 -2
  234. package/stacks/apollo.json +33 -2
  235. package/stacks/clay/pages/contacts.tsx.tmpl +2 -0
  236. package/stacks/clay/pages/lists.tsx.tmpl +14 -2
  237. package/stacks/gong/pages/calls.tsx.tmpl +9 -9
  238. package/stacks/gong/pages/contacts.tsx.tmpl +2 -0
  239. package/stacks/hootsuite/pages/contacts.tsx.tmpl +2 -0
  240. package/stacks/hootsuite.json +10 -0
  241. package/stacks/hubspot/pages/campaigns.tsx.tmpl +30 -0
  242. package/stacks/hubspot/pages/companies.tsx.tmpl +2 -0
  243. package/stacks/hubspot/pages/contacts.tsx.tmpl +6 -0
  244. package/stacks/hubspot/pages/deals.tsx.tmpl +4 -0
  245. package/stacks/hubspot/pages/forms.tsx.tmpl +36 -0
  246. package/stacks/hubspot/pages/inbox.tsx.tmpl +2 -2
  247. package/stacks/hubspot/pages/sequences.tsx.tmpl +8 -2
  248. package/stacks/hubspot/pages/web-analytics.tsx.tmpl +14 -0
  249. package/stacks/hubspot.json +45 -2
  250. package/stacks/intercom/pages/inbox-contact.tsx.tmpl +3 -3
  251. package/stacks/intercom.json +10 -0
  252. package/stacks/mailchimp/pages/contacts.tsx.tmpl +2 -0
  253. package/stacks/mailchimp/pages/forms.tsx.tmpl +36 -0
  254. package/stacks/mailchimp.json +2 -1
  255. package/stacks/outreach/pages/calls.tsx.tmpl +9 -9
  256. package/stacks/outreach/pages/campaigns.tsx.tmpl +27 -0
  257. package/stacks/outreach/pages/contacts.tsx.tmpl +2 -0
  258. package/stacks/outreach/pages/inbox-contact.tsx.tmpl +3 -3
  259. package/stacks/outreach/pages/sequences.tsx.tmpl +8 -2
  260. package/stacks/outreach.json +13 -2
  261. package/stacks/pipedrive/pages/contacts.tsx.tmpl +2 -0
  262. package/stacks/pipedrive/pages/inbox.tsx.tmpl +2 -2
  263. package/stacks/pipedrive.json +10 -0
  264. package/stacks/salesforce/pages/companies.tsx.tmpl +2 -0
  265. package/stacks/salesforce/pages/contacts.tsx.tmpl +6 -0
  266. package/stacks/salesforce/pages/deals.tsx.tmpl +4 -0
  267. package/stacks/salesforce.json +10 -0
  268. package/stacks/zoominfo/pages/contacts.tsx.tmpl +4 -0
  269. package/stacks/zoominfo/pages/lists.tsx.tmpl +14 -2
  270. package/stacks/zoominfo.json +20 -0
  271. package/styles/styles.css +1 -1
  272. package/templates/auth/auth-route.ts.tmpl +16 -0
  273. package/templates/auth/g8.auth.anonymous.ts.tmpl +15 -0
  274. package/templates/auth/g8.auth.basic.ts.tmpl +17 -0
  275. package/templates/auth/g8.auth.jwt.ts.tmpl +23 -0
  276. package/templates/auth/g8.auth.password.ts.tmpl +18 -0
  277. package/templates/auth/g8.auth.session.ts.tmpl +23 -0
  278. package/templates/auth/middleware.ts.tmpl +14 -0
  279. package/templates/next-app/README.md.tmpl +5 -0
@@ -12,6 +12,11 @@
12
12
  * or many (confirm, "N of M" report, 404 = already gone) - import a CSV/Excel file - review and merge duplicates -
13
13
  * add notes and tasks - hand a contact to other blocks through the slot props.
14
14
  *
15
+ * Record actions: assign an owner to a whole list (only with renderOwnerPicker, never for a selection), tag chips on the open
16
+ * contact plus add/remove tags on a selection and a tag manager, edit one field on a selection, verify emails and unlock
17
+ * locked contacts (both billable, behind ConfirmSpendDialog with the count taken from the selection), AI documents and
18
+ * signals/next best step tabs on the open contact. Each action also works from the row's "..." button.
19
+ *
15
20
  * SDK operations: none of its own. listContacts, getContact, getActivityFeed, duplicateCounts are `read`;
16
21
  * the forms, notes and tasks are `write`; deleteContact (rows, detail and bulk) is `destructive` and always goes
17
22
  * through ConfirmSpendDialog. Nothing here sends a message to a person.
@@ -22,7 +27,7 @@
22
27
  import { useEffect, useMemo, useState } from "react";
23
28
  import type { ReactNode } from "react";
24
29
  import { Button, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
25
- import { Building2, GitMerge, Lock, Plus, Trash2, Upload, Users } from "lucide-react";
30
+ import { Building2, GitMerge, Lock, Plus, Tags, Trash2, Upload, Users } from "lucide-react";
26
31
  import { ApiError } from "@/g8/components/api-error";
27
32
  import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
28
33
  import { EmptyState } from "@/g8/components/empty-state";
@@ -52,7 +57,18 @@ import { ActivityTimeline } from "./activity-timeline";
52
57
  import { DedupeReview } from "./dedupe-review";
53
58
  import { ImportWizard } from "./import-wizard";
54
59
  import { NotesPanel } from "./notes-panel";
60
+ import { OwnerAssignListButton } from "./owner-assign";
61
+ import type { OwnerPickerProps } from "./owner-assign";
55
62
  import { RecordForm } from "./record-form";
63
+ import { BulkUpdateButton } from "./bulk-update";
64
+ import { ContactDocumentsTab } from "./contact-documents-tab";
65
+ import { ContactInsightsTab } from "./contact-insights-tab";
66
+ import { UnlockInfoButton } from "./unlock-info-button";
67
+ import { VerifyEmailButton } from "./verify-email-button";
68
+ import { ContactRowActions } from "./contact-row-actions";
69
+ import { ContactTagChips } from "./contact-tag-chips";
70
+ import { TagAssignButton } from "./tag-assign";
71
+ import { TagManager } from "./tag-manager";
56
72
  import { TaskList } from "./task-list";
57
73
 
58
74
  export interface ContactsScreenProps {
@@ -69,6 +85,13 @@ export interface ContactsScreenProps {
69
85
  toolbarActions?: ReactNode;
70
86
  /** Extra bulk actions next to Delete (add to list, enrich, enroll...). */
71
87
  renderBulkActions?: (selection: { ids: number[]; rows: ContactRecord[]; clear: () => void }) => ReactNode;
88
+ /**
89
+ * Team-member picker for "Assign owner to all N in this list" (pass the pickers block's MemberPicker:
90
+ * `renderOwnerPicker={(p) => <MemberPicker label="New owner" {...p} />}`). Injected so crm does not depend on pickers.
91
+ * Without it the action is hidden. It is offered only when a list is in scope with no other filter, and never for a selection:
92
+ * the API assigns owners by filter, not by id.
93
+ */
94
+ renderOwnerPicker?: (props: OwnerPickerProps) => ReactNode;
72
95
  /** Called with a company id from the contact panel ("Open company"). Without it the button is hidden. */
73
96
  onOpenCompany?: (companyId: number) => void;
74
97
  className?: string;
@@ -85,6 +108,7 @@ function sortValue(row: ContactRecord, key: string): string | null | undefined {
85
108
  if (key === "title") return row.job_title;
86
109
  if (key === "company") return row.company_name;
87
110
  if (key === "location") return location(row);
111
+ if (key === "owner") return row.owner_name;
88
112
  return null;
89
113
  }
90
114
 
@@ -112,6 +136,7 @@ export function ContactsScreen(props: ContactsScreenProps) {
112
136
  const effectiveListId = listId ?? (filters.list ? Number(filters.list) : undefined);
113
137
  const [sheet, setSheet] = useState<"new" | "import" | "dupes" | null>(null);
114
138
  const [confirmDelete, setConfirmDelete] = useState(false);
139
+ const [tagManager, setTagManager] = useState(false);
115
140
  const [note, setNote] = useState<string | null>(null);
116
141
  const { sort, toggle: toggleSort } = useSort();
117
142
 
@@ -137,6 +162,8 @@ export function ContactsScreen(props: ContactsScreenProps) {
137
162
  void list.refetch();
138
163
  void dupes.refetch();
139
164
  });
165
+ /** The freshest copy of each selected row (a refetch after unlock or owner change replaces the stored one). */
166
+ const selectedRows = selection.ids.map((id) => list.rows.find((r) => r.id === id) ?? selection.selected.get(id)!).filter(Boolean);
140
167
  const allOnPage = rows.length > 0 && rows.every((r) => selection.selected.has(r.id));
141
168
  const reload = () => { void list.refetch(); void dupes.refetch(); };
142
169
  const set = (key: FilterKey, v: string) => urlFilters.set(key, v);
@@ -152,6 +179,8 @@ export function ContactsScreen(props: ContactsScreenProps) {
152
179
  actions={
153
180
  <>
154
181
  {props.toolbarActions}
182
+ {effectiveListId != null && Object.keys(apiFilters).length === 0 && props.renderOwnerPicker ? <OwnerAssignListButton renderPicker={props.renderOwnerPicker} listId={effectiveListId} total={list.loading ? null : list.total} onDone={reload} /> : null}
183
+ <Button variant="outline" size="sm" onClick={() => setTagManager(true)}><Tags className="size-4" aria-hidden />Manage tags</Button>
155
184
  <Button variant="outline" size="sm" onClick={() => setSheet("dupes")}><GitMerge className="size-4" aria-hidden />Duplicates{pending ? ` (${pending})` : ""}</Button>
156
185
  <Button variant="outline" size="sm" onClick={() => setSheet("import")}><Upload className="size-4" aria-hidden />Import</Button>
157
186
  <Button size="sm" onClick={() => setSheet("new")}><Plus className="size-4" aria-hidden />New contact</Button>
@@ -200,6 +229,10 @@ export function ContactsScreen(props: ContactsScreenProps) {
200
229
  <div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm" role="status">
201
230
  <span className="tabular-nums">{selection.ids.length} selected</span>
202
231
  {props.renderBulkActions?.({ ids: selection.ids, rows: selection.rows, clear: selection.clear })}
232
+ <TagAssignButton contactIds={selection.ids} onDone={reload} />
233
+ <BulkUpdateButton contactIds={selection.ids} onDone={reload} />
234
+ <VerifyEmailButton rows={selectedRows} onDone={reload} />
235
+ <UnlockInfoButton rows={selectedRows} onDone={reload} />
203
236
  <Button variant="destructive" size="sm" onClick={() => setConfirmDelete(true)}><Trash2 className="size-4" aria-hidden />Delete {selection.ids.length}</Button>
204
237
  <Button variant="ghost" size="sm" onClick={selection.clear}>Clear selection</Button>
205
238
  </div>
@@ -217,12 +250,13 @@ export function ContactsScreen(props: ContactsScreenProps) {
217
250
  <SortHead label="Company" sortKey="company" sort={sort} onSort={toggleSort} />
218
251
  <TableHead>Email</TableHead>
219
252
  <SortHead label="Location" sortKey="location" sort={sort} onSort={toggleSort} />
220
- {props.renderRowActions ? <TableHead className="w-8"><span className="sr-only">Actions</span></TableHead> : null}
253
+ <SortHead label="Owner" sortKey="owner" sort={sort} onSort={toggleSort} />
254
+ <TableHead className="w-8"><span className="sr-only">Actions</span></TableHead>
221
255
  </TableRow>
222
256
  </TableHeader>
223
257
  <TableBody>
224
258
  {list.loading && rows.length === 0
225
- ? Array.from({ length: 6 }, (_, i) => <TableRow key={`sk${i}`}><TableCell colSpan={6 + (props.renderRowActions ? 1 : 0)}><Skeleton className="h-6 w-full" /></TableCell></TableRow>)
259
+ ? Array.from({ length: 6 }, (_, i) => <TableRow key={`sk${i}`}><TableCell colSpan={8}><Skeleton className="h-6 w-full" /></TableCell></TableRow>)
226
260
  : rows.map((row) => {
227
261
  const name = contactName(row);
228
262
  return (
@@ -237,7 +271,10 @@ export function ContactsScreen(props: ContactsScreenProps) {
237
271
  <TableCell><Cell value={row.company_name} /></TableCell>
238
272
  <TableCell><Cell value={row.work_email} /></TableCell>
239
273
  <TableCell><Cell value={location(row)} /></TableCell>
240
- {props.renderRowActions ? <TableCell onClick={(e) => e.stopPropagation()}>{props.renderRowActions(row, { reload })}</TableCell> : null}
274
+ <TableCell><Cell value={row.owner_name} /></TableCell>
275
+ <TableCell onClick={(e) => e.stopPropagation()}>
276
+ <span className="flex items-center gap-1">{props.renderRowActions?.(row, { reload })}<ContactRowActions row={row} onDone={reload} /></span>
277
+ </TableCell>
241
278
  </TableRow>
242
279
  );
243
280
  })}
@@ -273,6 +310,7 @@ export function ContactsScreen(props: ContactsScreenProps) {
273
310
  <DedupeReview entity="contact" onChanged={reload} />
274
311
  </FormSheet>
275
312
 
313
+ <TagManager open={tagManager} onOpenChange={setTagManager} onChanged={reload} />
276
314
  <ConfirmSpendDialog
277
315
  open={confirmDelete}
278
316
  tier="destructive"
@@ -352,6 +390,7 @@ function ContactPanel({ full, setFull, contactId, seed, onClose, onChanged, onDe
352
390
  onCollapse={full ? () => setFull(false) : undefined}
353
391
  actions={
354
392
  <>
393
+ {partial ? null : <><VerifyEmailButton rows={[contact]} onDone={() => { void query.refetch(); onChanged(); }} /><UnlockInfoButton rows={[contact]} onDone={() => { void query.refetch(); onChanged(); }} /></>}
355
394
  {partial ? null : renderActions?.(contact, { reload: () => { void query.refetch(); bump(); onChanged(); } })}
356
395
  {contact.company?.id && onOpenCompany ? <Button variant="outline" size="sm" onClick={() => onOpenCompany(contact.company!.id as number)}><Building2 className="size-4" aria-hidden />Open company</Button> : null}
357
396
  <Button variant="outline" size="sm" disabled={partial} title={partial ? "Loading the full record" : undefined} onClick={() => setEditing(true)}>Edit</Button>
@@ -359,10 +398,12 @@ function ContactPanel({ full, setFull, contactId, seed, onClose, onChanged, onDe
359
398
  </>
360
399
  }
361
400
  tabs={[
362
- { id: "overview", label: "Overview", content: overview },
401
+ { id: "overview", label: "Overview", content: <div className="flex flex-col gap-4"><ContactTagChips contactId={contactId} onChanged={onChanged} />{overview}</div> },
363
402
  { id: "activity", label: "Activity", content: <ActivityTimeline entityType="contact" entityId={contactId} refreshKey={feedKey} /> },
364
403
  { id: "notes", label: "Notes", content: <NotesPanel entity="contact" entityId={contactId} onChanged={bump} /> },
365
404
  { id: "tasks", label: "Tasks", content: <TaskList entity="contact" entityId={contactId} onChanged={bump} /> },
405
+ { id: "documents", label: "AI documents", content: <ContactDocumentsTab contactId={contactId} contactName={name} /> },
406
+ { id: "insights", label: "Insights", content: <ContactInsightsTab contactId={contactId} /> },
366
407
  ...(partial ? [] : extraTabs).map((t) => ({ id: t.id, label: t.label, content: t.render(contact) })),
367
408
  ]}
368
409
  />
@@ -0,0 +1,132 @@
1
+ "use client";
2
+
3
+ /**
4
+ * OwnerAssignListButton: "Assign owner to all N contacts in this list". The ONLY owner action, and deliberately not a
5
+ * selection action.
6
+ *
7
+ * Why not for a selection: `POST /contacts/assign-owner` takes a FILTER (`list_id` + `filterModel`), not ids, and resolves
8
+ * it server-side across EVERY matching contact; its docstring warns the count is unbounded. Addressing a selection through
9
+ * an id filter would reassign the whole list or org if the key were ever ignored or renamed, so selections, rows and the
10
+ * open contact do not get an owner action here. There is no id-addressed owner write in the contract (bulk-update and
11
+ * PATCH take no owner), so that is an API gap: assign-owner needs an `ids` field.
12
+ *
13
+ * SDK operations, in order:
14
+ * 1. listTeamMembers GET /team-members via the injected picker (read, fetched when the picker opens)
15
+ * 2. assignContactOwner POST /contacts/assign-owner { owner_id, list_id, filterModel: {} } (write)
16
+ * Scope = the whole list, nothing else. N comes from the list's own count (the screen's total with no other filter
17
+ * applied), is shown in the confirm, and the button is disabled until that count has loaded. `owner_id: null`
18
+ * clears every owner in the list. Both paths go through ConfirmSpendDialog and name the list and N.
19
+ */
20
+
21
+ import { useState } from "react";
22
+ import type { ReactNode } from "react";
23
+ import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@graph8/sdk/ui";
24
+ import { UserCog } from "lucide-react";
25
+ import { ApiError } from "@/g8/components/api-error";
26
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
27
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
28
+ import { g8Post } from "@/g8/lib/g8-client";
29
+ import { plural } from "@/g8/lib/plural";
30
+
31
+ /** A team member, as the injected picker reports it: the user id the API takes, and a name for the confirm. */
32
+ export interface OwnerChoice { id: string; name: string }
33
+
34
+ export interface OwnerPickerProps {
35
+ value: string | null;
36
+ onChange: (member: OwnerChoice) => void;
37
+ }
38
+
39
+ export interface OwnerAssignListButtonProps {
40
+ /**
41
+ * Renders the team-member picker (the pickers block's MemberPicker: `renderPicker={(p) => <MemberPicker label="New owner" {...p} />}`).
42
+ * It is injected so crm does not depend on pickers. Without it the button renders nothing: an owner is never typed as a raw id.
43
+ */
44
+ renderPicker?: (props: OwnerPickerProps) => ReactNode;
45
+ listId: number;
46
+ /** Shown in the confirm: "Q4 target accounts". Falls back to "list <id>". */
47
+ listName?: string | null;
48
+ /** The number of contacts in the list, from a real read. Null while it loads: the button stays disabled. */
49
+ total: number | null;
50
+ onDone?: (result: { updated: number | null; ownerId: string | null }) => void;
51
+ variant?: "default" | "outline" | "secondary" | "ghost";
52
+ size?: "default" | "sm";
53
+ className?: string;
54
+ }
55
+
56
+ /** `updated` from the answer: `{ updated }`, `{ data: { updated } }`; null when the API did not say. */
57
+ export function updatedCount(data: unknown): number | null {
58
+ const o = data as { updated?: unknown; data?: { updated?: unknown } } | null | undefined;
59
+ const n = o?.updated ?? o?.data?.updated;
60
+ return typeof n === "number" ? n : null;
61
+ }
62
+
63
+ export function OwnerAssignListButton({ renderPicker, listId, listName, total, onDone, variant = "outline", size = "sm", className }: OwnerAssignListButtonProps) {
64
+ const [open, setOpen] = useState(false);
65
+ const [member, setMember] = useState<OwnerChoice | null>(null);
66
+ const [pending, setPending] = useState<"assign" | "remove" | null>(null);
67
+ const [done, setDone] = useState<{ updated: number | null; total: number; removed: boolean } | null>(null);
68
+ const label = listName?.trim() || `list ${listId}`;
69
+ const n = total ?? 0;
70
+
71
+ const run = useG8Mutation(async (v: { ownerId: string | null; total: number }) => {
72
+ const res = await g8Post<unknown>("/contacts/assign-owner", { owner_id: v.ownerId, list_id: listId, filterModel: {} });
73
+ return { updated: updatedCount(res.data), ownerId: v.ownerId, total: v.total };
74
+ }, {
75
+ invalidates: [["contacts", "list"], ["contacts", "detail"], ["contacts", "record"]],
76
+ onSuccess: (r) => { setPending(null); setDone({ updated: r.updated, total: r.total, removed: r.ownerId == null }); onDone?.({ updated: r.updated, ownerId: r.ownerId }); },
77
+ });
78
+
79
+ const close = () => { setOpen(false); setMember(null); setDone(null); setPending(null); run.reset(); };
80
+ const short = done && done.updated != null && done.updated !== done.total;
81
+ const ready = total != null && total > 0;
82
+ if (!renderPicker) return null;
83
+
84
+ return (
85
+ <>
86
+ <Button className={className} variant={variant} size={size} disabled={!ready} title={total == null ? "Loading the list size" : total === 0 ? "This list has no contacts" : undefined} onClick={() => setOpen(true)}>
87
+ <UserCog className="size-4" aria-hidden />Assign owner to all{total != null ? ` ${total.toLocaleString()}` : ""}
88
+ </Button>
89
+ <Dialog open={open} onOpenChange={(o) => (o ? setOpen(true) : close())}>
90
+ <DialogContent>
91
+ <DialogHeader>
92
+ <DialogTitle>Assign an owner to the whole list</DialogTitle>
93
+ <DialogDescription>This changes every contact in {label}, {plural(n, "contact")}. It does not use your selection.</DialogDescription>
94
+ </DialogHeader>
95
+ {done ? (
96
+ <div role="status" className="text-sm">
97
+ <p className="font-medium">{done.updated == null ? "Done." : `${done.removed ? "Removed the owner from" : "Assigned the owner to"} ${plural(done.updated, "contact")}.`}</p>
98
+ {short ? <p className="mt-1 text-destructive">The list had {done.total.toLocaleString()} contacts, but the server updated {done.updated!.toLocaleString()}.</p> : null}
99
+ </div>
100
+ ) : (
101
+ <>
102
+ {renderPicker?.({ value: member?.id ?? null, onChange: (m) => { setMember({ id: m.id, name: m.name }); run.reset(); } })}
103
+ {run.error && !pending ? <ApiError error={run.error} title="Could not change the owner" /> : null}
104
+ </>
105
+ )}
106
+ <DialogFooter>
107
+ {done ? <Button onClick={close}>Done</Button> : (
108
+ <>
109
+ <Button variant="ghost" onClick={close}>Cancel</Button>
110
+ <Button variant="outline" disabled={run.loading} onClick={() => setPending("remove")}>Remove all owners</Button>
111
+ <Button disabled={!member || run.loading} onClick={() => setPending("assign")}>Assign owner</Button>
112
+ </>
113
+ )}
114
+ </DialogFooter>
115
+ </DialogContent>
116
+ </Dialog>
117
+ <ConfirmSpendDialog
118
+ open={pending != null}
119
+ tier="destructive"
120
+ title={pending === "remove" ? `Remove the owner from all ${plural(n, "contact")} in ${label}` : `Make ${member?.name ?? "this person"} the owner of all ${plural(n, "contact")} in ${label}`}
121
+ description={pending === "remove"
122
+ ? "Every contact in the list will have no owner. The previous owners are not remembered, so this cannot be undone from here."
123
+ : "Every contact in the list gets this owner, replacing whoever owns it now. The previous owners are not remembered."}
124
+ affected={n}
125
+ confirmLabel={pending === "remove" ? "Remove owners" : "Assign owner"}
126
+ confirming={run.loading}
127
+ onConfirm={() => void run.mutate({ ownerId: pending === "remove" ? null : member?.id ?? null, total: n })}
128
+ onCancel={() => setPending(null)}
129
+ />
130
+ </>
131
+ );
132
+ }
@@ -0,0 +1,79 @@
1
+ "use client";
2
+
3
+ /**
4
+ * RecordOpenLinks: "Open contact" and "Open company" buttons for something that only knows ids (a call log entry, a
5
+ * website visitor). The ids are CHECKED against your CRM first, so a button is only shown for a record that exists
6
+ * and, when you pass `expectCompanyDomain`, for a company whose domain matches (event ids and CRM ids are not always
7
+ * the same id space). A record that cannot be loaded renders nothing, never a link to the wrong or a missing page.
8
+ *
9
+ * SDK operations, in order:
10
+ * 1. getContact GET /contacts/{contact_id} (read; only with a numeric `contactId`)
11
+ * 2. getCompany GET /companies/{company_id} (read; only with a `companyId` that the contact did not already give)
12
+ * Nothing here is billable, external or destructive.
13
+ */
14
+
15
+ import { Button } from "@graph8/sdk/ui";
16
+ import { Building2, UserRound } from "lucide-react";
17
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
18
+ import { g8Get } from "@/g8/lib/g8-client";
19
+ import type { ContactRecord } from "../lib/crm-types";
20
+
21
+ export interface RecordOpenLinksProps {
22
+ /** A CRM contact id (number or numeric string). Anything else is ignored. */
23
+ contactId?: number | string | null;
24
+ /** A CRM company id. Optional: a contact that has a company supplies it. */
25
+ companyId?: number | string | null;
26
+ /** When set, a company is linked only if its domain equals this (case-insensitive). */
27
+ expectCompanyDomain?: string | null;
28
+ /** When set, the contact is linked only if its name equals this (case-insensitive, extra spaces ignored). */
29
+ expectContactName?: string | null;
30
+ onOpenContact?: (contactId: number) => void;
31
+ onOpenCompany?: (companyId: number) => void;
32
+ size?: "default" | "sm";
33
+ }
34
+
35
+ const idOf = (v: number | string | null | undefined): number | null => {
36
+ const s = String(v ?? "").trim();
37
+ return /^\d+$/.test(s) ? Number(s) : null;
38
+ };
39
+ const nameKey = (n: string | null | undefined): string => (n ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean).join(" ");
40
+ const domainOf = (d: string | null | undefined): string => (d ?? "").trim().toLowerCase().replace(/^www\./, "");
41
+
42
+ export function RecordOpenLinks({ contactId, companyId, expectCompanyDomain, expectContactName, onOpenContact, onOpenCompany, size = "sm" }: RecordOpenLinksProps) {
43
+ const cid = idOf(contactId);
44
+ const contact = useG8Query(
45
+ async (signal) => (await g8Get<ContactRecord>(`/contacts/${cid}`, undefined, { signal })).data ?? null,
46
+ [cid],
47
+ { enabled: cid != null && Boolean(onOpenContact || onOpenCompany), key: ["contacts", "open-links", cid] },
48
+ );
49
+ const c = contact.data;
50
+ // A contact whose name is not the expected one is some other person that happens to share the id: link nothing from it.
51
+ const contactOk = Boolean(c) && (!expectContactName || nameKey(expectContactName) === nameKey(c?.full_name || [c?.first_name, c?.last_name].filter(Boolean).join(" ")));
52
+ const fromContact = c && contactOk ? idOf(c.company_id ?? c.company?.id) : null;
53
+ const ownCompany = idOf(companyId);
54
+ const coId = fromContact ?? ownCompany;
55
+ // A company id that came from the caller (not from the contact) is verified by loading it.
56
+ const needsCheck = fromContact == null && ownCompany != null && Boolean(onOpenCompany);
57
+ const company = useG8Query(
58
+ async (signal) => (await g8Get<{ id?: number; domain?: string | null }>(`/companies/${ownCompany}`, undefined, { signal })).data ?? null,
59
+ [ownCompany],
60
+ { enabled: needsCheck && (cid == null || contact.data !== undefined || Boolean(contact.error)), key: ["companies", "open-links", ownCompany] },
61
+ );
62
+ const companyOk = fromContact != null || (needsCheck && company.data != null && (!expectCompanyDomain || domainOf(company.data.domain) === domainOf(expectCompanyDomain)));
63
+ return (
64
+ <>
65
+ {onOpenContact && contactOk && cid != null ? (
66
+ <Button size={size} variant="outline" onClick={() => onOpenContact(cid)}>
67
+ <UserRound className="mr-1 size-4" aria-hidden />
68
+ Open contact
69
+ </Button>
70
+ ) : null}
71
+ {onOpenCompany && coId != null && companyOk ? (
72
+ <Button size={size} variant="outline" onClick={() => onOpenCompany(coId)}>
73
+ <Building2 className="mr-1 size-4" aria-hidden />
74
+ Open company
75
+ </Button>
76
+ ) : null}
77
+ </>
78
+ );
79
+ }
@@ -0,0 +1,112 @@
1
+ "use client";
2
+
3
+ /**
4
+ * TagAssignButton: add tags to, or remove tags from, a selection of contacts. A dialog lists the org's tags as checkboxes;
5
+ * the person picks the tags and whether to add or remove them.
6
+ *
7
+ * SDK operations, in order:
8
+ * 1. listContactTags GET /contacts/tags (read, via useContactTags, when opened)
9
+ * 2. assignContactTags POST /contacts/tags/assign { tag_ids, contact_ids } (write, idempotent)
10
+ * 3. unassignContactTags POST /contacts/tags/unassign { tag_ids, contact_ids } (write; tag definitions survive)
11
+ *
12
+ * One request for the whole selection (the API takes up to 200,000 contacts). The answer's `tagged` / `untagged` count is
13
+ * the number of contacts that changed; contacts that already had the tag are not counted, and the dialog says so.
14
+ */
15
+
16
+ import { useState } from "react";
17
+ import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@graph8/sdk/ui";
18
+ import { Tags } from "lucide-react";
19
+ import { ApiError } from "@/g8/components/api-error";
20
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
21
+ import { g8Post } from "@/g8/lib/g8-client";
22
+ import { plural } from "@/g8/lib/plural";
23
+ import { useContactTags } from "../hooks/use-contact-tags";
24
+ import { TagChip } from "./contact-tag-chips";
25
+
26
+ export interface TagAssignButtonProps {
27
+ contactIds: ReadonlyArray<number>;
28
+ onDone?: (result: { changed: number | null; mode: "add" | "remove" }) => void;
29
+ label?: string;
30
+ variant?: "default" | "outline" | "secondary" | "ghost";
31
+ size?: "default" | "sm";
32
+ className?: string;
33
+ defaultOpen?: boolean;
34
+ onClosed?: () => void;
35
+ }
36
+
37
+ type Mode = "add" | "remove";
38
+
39
+ export function TagAssignButton({ contactIds, onDone, label = "Tags", variant = "outline", size = "sm", className, defaultOpen = false, onClosed }: TagAssignButtonProps) {
40
+ const [open, setOpen] = useState(defaultOpen);
41
+ const [mode, setMode] = useState<Mode>("add");
42
+ const [picked, setPicked] = useState<ReadonlySet<string>>(new Set());
43
+ const [done, setDone] = useState<{ changed: number | null; mode: Mode; total: number } | null>(null);
44
+ const tags = useContactTags({ enabled: open });
45
+
46
+ const apply = useG8Mutation(async (v: { mode: Mode; tagIds: string[]; ids: number[] }) => {
47
+ const body = { tag_ids: v.tagIds, contact_ids: v.ids };
48
+ const res = v.mode === "add" ? await g8Post<Record<string, unknown>>("/contacts/tags/assign", body) : await g8Post<Record<string, unknown>>("/contacts/tags/unassign", body);
49
+ const n = res.data?.[v.mode === "add" ? "tagged" : "untagged"];
50
+ return { changed: typeof n === "number" ? n : null, mode: v.mode, total: v.ids.length };
51
+ }, {
52
+ invalidates: [["contact-tags"], ["contacts", "tags"], ["contacts", "list"]],
53
+ onSuccess: (r) => { setDone(r); onDone?.({ changed: r.changed, mode: r.mode }); },
54
+ });
55
+
56
+ const close = () => { setOpen(false); setPicked(new Set()); setDone(null); apply.reset(); onClosed?.(); };
57
+ const toggle = (id: string) => setPicked((s) => { const n = new Set(s); if (!n.delete(id)) n.add(id); return n; });
58
+ const count = contactIds.length;
59
+
60
+ return (
61
+ <>
62
+ {defaultOpen ? null : <Button className={className} variant={variant} size={size} disabled={count === 0} onClick={() => setOpen(true)}><Tags className="size-4" aria-hidden />{label}</Button>}
63
+ <Dialog open={open} onOpenChange={(o) => (o ? setOpen(true) : close())}>
64
+ <DialogContent>
65
+ <DialogHeader>
66
+ <DialogTitle>Tag {plural(count, "contact")}</DialogTitle>
67
+ <DialogDescription>Tags group contacts outside lists. Adding a tag a contact already has changes nothing.</DialogDescription>
68
+ </DialogHeader>
69
+ {done ? (
70
+ <p role="status" className="text-sm font-medium">
71
+ {done.changed == null ? "Done." : done.mode === "add" ? `Tagged ${plural(done.changed, "contact")}.` : `Removed the tags from ${plural(done.changed, "contact")}.`}
72
+ {done.changed != null && done.changed < done.total ? <span className="block font-normal text-muted-foreground">{(done.total - done.changed).toLocaleString()} already matched and did not change.</span> : null}
73
+ </p>
74
+ ) : (
75
+ <>
76
+ <div role="radiogroup" aria-label="Action" className="flex gap-2">
77
+ {(["add", "remove"] as const).map((m) => (
78
+ <Button key={m} role="radio" aria-checked={mode === m} variant={mode === m ? "default" : "outline"} size="sm" onClick={() => { setMode(m); apply.reset(); }}>{m === "add" ? "Add tags" : "Remove tags"}</Button>
79
+ ))}
80
+ </div>
81
+ {tags.error ? <ApiError error={tags.error} title="Could not load tags" onRetry={() => void tags.refetch()} /> : null}
82
+ {tags.loading ? <p className="text-sm text-muted-foreground" role="status">Loading tags</p> : null}
83
+ {!tags.loading && !tags.error && tags.tags.length === 0 ? <p className="text-sm text-muted-foreground">No tags yet. Create one in Manage tags.</p> : null}
84
+ <ul className="flex max-h-64 flex-col gap-1 overflow-y-auto">
85
+ {tags.tags.map((t) => (
86
+ <li key={t.id}>
87
+ <label className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-muted">
88
+ <input type="checkbox" checked={picked.has(t.id)} onChange={() => toggle(t.id)} aria-label={t.name} />
89
+ <TagChip tag={t} />
90
+ {t.contact_count != null ? <span className="ml-auto text-xs tabular-nums text-muted-foreground">{t.contact_count.toLocaleString()}</span> : null}
91
+ </label>
92
+ </li>
93
+ ))}
94
+ </ul>
95
+ {apply.error ? <ApiError error={apply.error} title="Could not change the tags" /> : null}
96
+ </>
97
+ )}
98
+ <DialogFooter>
99
+ {done ? <Button onClick={close}>Done</Button> : (
100
+ <>
101
+ <Button variant="ghost" onClick={close}>Cancel</Button>
102
+ <Button loading={apply.loading} disabled={picked.size === 0 || count === 0} onClick={() => void apply.mutate({ mode, tagIds: [...picked], ids: [...contactIds] })}>
103
+ {mode === "add" ? `Add ${plural(picked.size, "tag")}` : `Remove ${plural(picked.size, "tag")}`}
104
+ </Button>
105
+ </>
106
+ )}
107
+ </DialogFooter>
108
+ </DialogContent>
109
+ </Dialog>
110
+ </>
111
+ );
112
+ }