@aglyn/plugins-crm 1.0.0-beta.217 → 1.0.0-beta.219

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 (249) hide show
  1. package/package.json +14 -13
  2. package/src/index.d.ts +1 -0
  3. package/src/index.js.map +1 -1
  4. package/src/lib/components/companies-bulk-bar.js +12 -4
  5. package/src/lib/components/companies-bulk-bar.js.map +1 -1
  6. package/src/lib/components/company-detail-page.js +11 -0
  7. package/src/lib/components/company-detail-page.js.map +1 -1
  8. package/src/lib/components/company-edit-drawer.js +12 -2
  9. package/src/lib/components/company-edit-drawer.js.map +1 -1
  10. package/src/lib/components/company-import-drawer.d.ts +1 -1
  11. package/src/lib/components/company-picker.js +10 -3
  12. package/src/lib/components/company-picker.js.map +1 -1
  13. package/src/lib/components/contact-detail-page.d.ts +4 -4
  14. package/src/lib/components/contact-detail-page.js +50 -53
  15. package/src/lib/components/contact-detail-page.js.map +1 -1
  16. package/src/lib/components/contact-list-columns.d.ts +3 -0
  17. package/src/lib/components/contact-list-columns.js +21 -5
  18. package/src/lib/components/contact-list-columns.js.map +1 -1
  19. package/src/lib/components/contact-merge-dialog.js +2 -1
  20. package/src/lib/components/contact-merge-dialog.js.map +1 -1
  21. package/src/lib/components/contact-timeline-card.js +4 -3
  22. package/src/lib/components/contact-timeline-card.js.map +1 -1
  23. package/src/lib/components/contacts-bulk-bar.d.ts +3 -3
  24. package/src/lib/components/contacts-bulk-bar.js +24 -51
  25. package/src/lib/components/contacts-bulk-bar.js.map +1 -1
  26. package/src/lib/components/contacts-section.js +9 -3
  27. package/src/lib/components/contacts-section.js.map +1 -1
  28. package/src/lib/components/crm-export-all-button.d.ts +1 -1
  29. package/src/lib/components/crm-export-all-button.js.map +1 -1
  30. package/src/lib/components/crm-send-email-dialog.js +9 -6
  31. package/src/lib/components/crm-send-email-dialog.js.map +1 -1
  32. package/src/lib/components/csv-import-drawer.js +2 -2
  33. package/src/lib/components/csv-import-drawer.js.map +1 -1
  34. package/src/lib/components/deal-detail-page.js +18 -2
  35. package/src/lib/components/deal-detail-page.js.map +1 -1
  36. package/src/lib/components/deal-edit-drawer.js +10 -0
  37. package/src/lib/components/deal-edit-drawer.js.map +1 -1
  38. package/src/lib/components/deals-bulk-bar.js +13 -5
  39. package/src/lib/components/deals-bulk-bar.js.map +1 -1
  40. package/src/lib/components/lead-campaigns-card.js +6 -0
  41. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  42. package/src/lib/components/lead-detail-page.js +25 -5
  43. package/src/lib/components/lead-detail-page.js.map +1 -1
  44. package/src/lib/components/lead-properties-card.js +6 -0
  45. package/src/lib/components/lead-properties-card.js.map +1 -1
  46. package/src/lib/components/lead-surfaces-note.js +4 -5
  47. package/src/lib/components/lead-surfaces-note.js.map +1 -1
  48. package/src/lib/components/lead-unqualify-dialog.js +6 -0
  49. package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
  50. package/src/lib/components/leads-bulk-bar.js +28 -3
  51. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  52. package/src/lib/components/leads-section.js +36 -7
  53. package/src/lib/components/leads-section.js.map +1 -1
  54. package/src/lib/components/org-lead-surfaces-note.js +6 -2
  55. package/src/lib/components/org-lead-surfaces-note.js.map +1 -1
  56. package/src/lib/components/recipes-card.js +9 -4
  57. package/src/lib/components/recipes-card.js.map +1 -1
  58. package/src/lib/components/record-sharing-card.d.ts +57 -0
  59. package/src/lib/components/record-sharing-card.js +244 -0
  60. package/src/lib/components/record-sharing-card.js.map +1 -0
  61. package/src/lib/components/settings-section.js +7 -0
  62. package/src/lib/components/settings-section.js.map +1 -1
  63. package/src/lib/components/share-records-dialog.d.ts +28 -0
  64. package/src/lib/components/share-records-dialog.js +184 -0
  65. package/src/lib/components/share-records-dialog.js.map +1 -0
  66. package/src/lib/components/sharing-rule-drawer.d.ts +37 -0
  67. package/src/lib/components/sharing-rule-drawer.js +374 -0
  68. package/src/lib/components/sharing-rule-drawer.js.map +1 -0
  69. package/src/lib/components/sharing-rules-card.d.ts +24 -0
  70. package/src/lib/components/sharing-rules-card.js +287 -0
  71. package/src/lib/components/sharing-rules-card.js.map +1 -0
  72. package/src/lib/components/use-crm-api.d.ts +1 -1
  73. package/src/lib/components/use-crm-api.js.map +1 -1
  74. package/src/lib/components/use-site-route-context.d.ts +25 -0
  75. package/src/lib/components/use-site-route-context.js +58 -0
  76. package/src/lib/components/use-site-route-context.js.map +1 -0
  77. package/src/lib/constants/api-routes.d.ts +31 -0
  78. package/src/lib/constants/api-routes.js +27 -1
  79. package/src/lib/constants/api-routes.js.map +1 -1
  80. package/src/lib/declarations.console-server.d.ts +37 -0
  81. package/src/lib/declarations.console-server.js +103 -0
  82. package/src/lib/declarations.console-server.js.map +1 -0
  83. package/src/lib/declarations.server.d.ts +6 -0
  84. package/src/lib/declarations.server.js +33 -0
  85. package/src/lib/declarations.server.js.map +1 -1
  86. package/src/lib/hooks/use-contact-remove.d.ts +35 -0
  87. package/src/lib/hooks/use-contact-remove.js +77 -0
  88. package/src/lib/hooks/use-contact-remove.js.map +1 -0
  89. package/src/lib/hooks/use-crm-sharing.d.ts +30 -0
  90. package/src/lib/hooks/use-crm-sharing.js +109 -0
  91. package/src/lib/hooks/use-crm-sharing.js.map +1 -0
  92. package/src/lib/model/companies-csv.d.ts +1 -1
  93. package/src/lib/model/companies-csv.js +2 -2
  94. package/src/lib/model/companies-csv.js.map +1 -1
  95. package/src/lib/model/contact-holder.d.ts +68 -0
  96. package/src/lib/model/contact-holder.js +129 -0
  97. package/src/lib/model/contact-holder.js.map +1 -0
  98. package/src/lib/model/contact-interaction-links.d.ts +46 -0
  99. package/src/lib/model/contact-interaction-links.js +65 -0
  100. package/src/lib/model/contact-interaction-links.js.map +1 -0
  101. package/src/lib/model/contact-merge.d.ts +136 -0
  102. package/src/lib/model/contact-merge.js +412 -0
  103. package/src/lib/model/contact-merge.js.map +1 -0
  104. package/src/lib/model/contact-record.d.ts +7 -1
  105. package/src/lib/model/contact-record.js +6 -5
  106. package/src/lib/model/contact-record.js.map +1 -1
  107. package/src/lib/model/contact-remove.d.ts +48 -0
  108. package/src/lib/model/contact-remove.js +22 -0
  109. package/src/lib/model/contact-remove.js.map +1 -0
  110. package/src/lib/model/contacts-bulk-writes.d.ts +2 -71
  111. package/src/lib/model/contacts-bulk-writes.js +2 -82
  112. package/src/lib/model/contacts-bulk-writes.js.map +1 -1
  113. package/src/lib/model/contacts-csv.d.ts +2 -2
  114. package/src/lib/model/contacts-csv.js +2 -2
  115. package/src/lib/model/contacts-csv.js.map +1 -1
  116. package/src/lib/model/crm-csv.d.ts +239 -0
  117. package/src/lib/model/crm-csv.js +467 -0
  118. package/src/lib/model/crm-csv.js.map +1 -0
  119. package/src/lib/model/crm-digest.d.ts +185 -0
  120. package/src/lib/model/crm-digest.js +272 -0
  121. package/src/lib/model/crm-digest.js.map +1 -0
  122. package/src/lib/model/crm-import.d.ts +7 -1
  123. package/src/lib/model/crm-import.js +2 -1
  124. package/src/lib/model/crm-import.js.map +1 -1
  125. package/src/lib/model/crm-record-routes.d.ts +5 -0
  126. package/src/lib/model/crm-record-routes.js +7 -0
  127. package/src/lib/model/crm-record-routes.js.map +1 -1
  128. package/src/lib/model/crm-routes.d.ts +6 -5
  129. package/src/lib/model/crm-routes.js +7 -7
  130. package/src/lib/model/crm-routes.js.map +1 -1
  131. package/src/lib/model/crm-search-sources.d.ts +15 -0
  132. package/src/lib/model/crm-search-sources.js +162 -0
  133. package/src/lib/model/crm-search-sources.js.map +1 -0
  134. package/src/lib/model/crm-sharing-api.d.ts +32 -0
  135. package/src/lib/model/crm-sharing-api.js +59 -0
  136. package/src/lib/model/crm-sharing-api.js.map +1 -0
  137. package/src/lib/model/crm-sharing.d.ts +333 -0
  138. package/src/lib/model/crm-sharing.js +603 -0
  139. package/src/lib/model/crm-sharing.js.map +1 -0
  140. package/src/lib/model/deals-csv.d.ts +1 -1
  141. package/src/lib/model/deals-csv.js +4 -5
  142. package/src/lib/model/deals-csv.js.map +1 -1
  143. package/src/lib/model/lead-filters.d.ts +1 -1
  144. package/src/lib/model/leads-csv.d.ts +1 -1
  145. package/src/lib/model/leads-csv.js +1 -1
  146. package/src/lib/model/leads-csv.js.map +1 -1
  147. package/src/lib/model/tasks-csv.d.ts +1 -1
  148. package/src/lib/model/tasks-csv.js +1 -1
  149. package/src/lib/model/tasks-csv.js.map +1 -1
  150. package/src/lib/plan-entitlements.d.ts +75 -0
  151. package/src/lib/plan-entitlements.js +56 -0
  152. package/src/lib/plan-entitlements.js.map +1 -0
  153. package/src/lib/plugin.js +3 -0
  154. package/src/lib/plugin.js.map +1 -1
  155. package/src/lib/server/api-v1/contacts-merge.d.ts +20 -0
  156. package/src/lib/server/api-v1/contacts-merge.js +120 -0
  157. package/src/lib/server/api-v1/contacts-merge.js.map +1 -0
  158. package/src/lib/server/api-v1/contacts.d.ts +18 -0
  159. package/src/lib/server/api-v1/contacts.js +1076 -0
  160. package/src/lib/server/api-v1/contacts.js.map +1 -0
  161. package/src/lib/server/api-v1/crm-activities.d.ts +18 -0
  162. package/src/lib/server/api-v1/crm-activities.js +269 -0
  163. package/src/lib/server/api-v1/crm-activities.js.map +1 -0
  164. package/src/lib/server/api-v1/crm-companies.d.ts +18 -0
  165. package/src/lib/server/api-v1/crm-companies.js +391 -0
  166. package/src/lib/server/api-v1/crm-companies.js.map +1 -0
  167. package/src/lib/server/api-v1/crm-deals.d.ts +18 -0
  168. package/src/lib/server/api-v1/crm-deals.js +631 -0
  169. package/src/lib/server/api-v1/crm-deals.js.map +1 -0
  170. package/src/lib/server/api-v1/crm-email-templates.d.ts +18 -0
  171. package/src/lib/server/api-v1/crm-email-templates.js +336 -0
  172. package/src/lib/server/api-v1/crm-email-templates.js.map +1 -0
  173. package/src/lib/server/api-v1/crm-leads.d.ts +18 -0
  174. package/src/lib/server/api-v1/crm-leads.js +832 -0
  175. package/src/lib/server/api-v1/crm-leads.js.map +1 -0
  176. package/src/lib/server/api-v1/crm-pipelines.d.ts +76 -0
  177. package/src/lib/server/api-v1/crm-pipelines.js +146 -0
  178. package/src/lib/server/api-v1/crm-pipelines.js.map +1 -0
  179. package/src/lib/server/api-v1/crm-shared.d.ts +271 -0
  180. package/src/lib/server/api-v1/crm-shared.js +436 -0
  181. package/src/lib/server/api-v1/crm-shared.js.map +1 -0
  182. package/src/lib/server/api-v1/crm-tasks.d.ts +18 -0
  183. package/src/lib/server/api-v1/crm-tasks.js +398 -0
  184. package/src/lib/server/api-v1/crm-tasks.js.map +1 -0
  185. package/src/lib/server/api-v1/openapi.d.ts +29 -0
  186. package/src/lib/server/api-v1/openapi.js +985 -0
  187. package/src/lib/server/api-v1/openapi.js.map +1 -0
  188. package/src/lib/server/api-v1/usage.d.ts +26 -0
  189. package/src/lib/server/api-v1/usage.js +73 -0
  190. package/src/lib/server/api-v1/usage.js.map +1 -0
  191. package/src/lib/server/consent-group-participant.js +1 -1
  192. package/src/lib/server/consent-group-participant.js.map +1 -1
  193. package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
  194. package/src/lib/server/contact-lifecycle-floor.js +91 -0
  195. package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
  196. package/src/lib/server/contact-remove.d.ts +75 -0
  197. package/src/lib/server/contact-remove.js +244 -0
  198. package/src/lib/server/contact-remove.js.map +1 -0
  199. package/src/lib/server/contact-update.js +2 -1
  200. package/src/lib/server/contact-update.js.map +1 -1
  201. package/src/lib/server/contacts-import.d.ts +47 -11
  202. package/src/lib/server/contacts-import.js +171 -71
  203. package/src/lib/server/contacts-import.js.map +1 -1
  204. package/src/lib/server/contacts-merge.d.ts +1 -1
  205. package/src/lib/server/contacts-merge.js +3 -2
  206. package/src/lib/server/contacts-merge.js.map +1 -1
  207. package/src/lib/server/convert-host-lead.d.ts +189 -0
  208. package/src/lib/server/convert-host-lead.js +518 -0
  209. package/src/lib/server/convert-host-lead.js.map +1 -0
  210. package/src/lib/server/crm-sharing.d.ts +108 -0
  211. package/src/lib/server/crm-sharing.js +790 -0
  212. package/src/lib/server/crm-sharing.js.map +1 -0
  213. package/src/lib/server/daily-digest-route.d.ts +107 -0
  214. package/src/lib/server/daily-digest-route.js +588 -0
  215. package/src/lib/server/daily-digest-route.js.map +1 -0
  216. package/src/lib/server/export-route.d.ts +60 -0
  217. package/src/lib/server/export-route.js +447 -0
  218. package/src/lib/server/export-route.js.map +1 -0
  219. package/src/lib/server/inbound-route.d.ts +74 -0
  220. package/src/lib/server/inbound-route.js +257 -0
  221. package/src/lib/server/inbound-route.js.map +1 -0
  222. package/src/lib/server/lead-convert.d.ts +1 -1
  223. package/src/lib/server/lead-convert.js +2 -2
  224. package/src/lib/server/lead-convert.js.map +1 -1
  225. package/src/lib/server/merge-contacts.d.ts +55 -0
  226. package/src/lib/server/merge-contacts.js +274 -0
  227. package/src/lib/server/merge-contacts.js.map +1 -0
  228. package/src/lib/server/pipeline-record-index.d.ts +36 -0
  229. package/src/lib/server/pipeline-record-index.js +87 -0
  230. package/src/lib/server/pipeline-record-index.js.map +1 -0
  231. package/src/lib/server/record-facts.js +2 -1
  232. package/src/lib/server/record-facts.js.map +1 -1
  233. package/src/lib/server/task-reminders-route.d.ts +82 -0
  234. package/src/lib/server/task-reminders-route.js +497 -0
  235. package/src/lib/server/task-reminders-route.js.map +1 -0
  236. package/src/lib/server-deal-stage.js +2 -1
  237. package/src/lib/server-deal-stage.js.map +1 -1
  238. package/src/lib/server.d.ts +5 -2
  239. package/src/lib/server.js +47 -2
  240. package/src/lib/server.js.map +1 -1
  241. package/src/lib/testing/stand-in-site-record-routes.d.ts +26 -0
  242. package/src/lib/testing/stand-in-site-record-routes.js +55 -0
  243. package/src/lib/testing/stand-in-site-record-routes.js.map +1 -0
  244. package/src/lib/usage-axes.d.ts +35 -0
  245. package/src/lib/usage-axes.js +68 -0
  246. package/src/lib/usage-axes.js.map +1 -0
  247. package/src/lib/components/use-emails-hub-path.d.ts +0 -24
  248. package/src/lib/components/use-emails-hub-path.js +0 -59
  249. package/src/lib/components/use-emails-hub-path.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-list-columns.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n activityTimeLabel,\n CONTACT_LIFECYCLE_STAGE_LABELS,\n CONTACT_SOURCE_LABELS,\n EMAIL_STATE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn'\nimport {\n hiddenFilterColumns,\n listFilterColumn,\n} from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { Chip, Stack, Tooltip, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n CONTACT_LIST_FILTER_FIELDS,\n CONTACT_LIST_FILTER_HEADERS,\n} from '../constants/contact-filters'\nimport type { ContactRecord } from '../model/contact-record'\nimport { CrmEmailStateChip } from './crm-email-state-chip'\nimport { nextActivityColumn } from './crm-next-activity-column'\n\n/**\n * The filterable fields that get a column. The rest of\n * `CONTACT_LIST_FILTER_FIELDS` still reaches the filter panel, hidden.\n */\n/**\n * The declared filter fields that ARE table columns, so the hidden\n * filter-only columns are built for the others alone. Owner and Stage\n * joined it when the grammar learned them (AGL-2617): each is a shown\n * column already, and a second, hidden one under the same field would be\n * a duplicate the grid refuses.\n */\nexport const CONTACT_FILTER_COLUMNS = [\n 'name',\n 'ownerUid',\n 'lifecycleStage',\n 'sources',\n 'tags',\n 'updatedAt',\n 'nextTaskAtMs',\n 'emailState',\n]\n\n/**\n * The columns that ship HIDDEN, for the grid's visibility model (AGL-2616).\n *\n * \"Last engaged\" is a fact most lists do not need on screen and every list\n * can turn on from the column menu; shipping it visible would widen a\n * table that already carries seven columns for a figure only a marketing\n * reader scans.\n */\nexport const CONTACT_OPTIONAL_COLUMNS = ['lastEmailEngagementAtMs']\n\nexport interface ContactListColumnOptions {\n /** The owner's name for a uid — the roster's, or the uid itself. */\n memberName: (uid: string) => string\n /**\n * How a site reads, by document id — handed in at the ORGANIZATION level\n * only (AGL-2630), where it turns on the \"Known by\" column: the sites\n * that have captured each person. Absent under a site, where every row\n * is known by the site and the column would say so on every line.\n */\n siteName?: (hostId: string) => string\n /**\n * One clock for every row of one paint, so two contacts engaged a second\n * apart cannot read \"just now\" and \"1 min ago\". Defaults to the paint's\n * own `Date.now()`.\n */\n nowMs?: number\n}\n\n/**\n * The contacts list's columns (AGL-2596), in their own module so the list\n * file stays about the QUERY and the toolbar while other surfaces — a bulk\n * bar, an import — grow beside it.\n *\n * Owner and Stage read off the flattened row, which is already this\n * holder's facet (`contactRecordFromDoc`), so neither column can show\n * another holder's assignment. Filtering by either is the list's: the panel\n * names the field and the list asks the holder's facet key of its query\n * (`contactQueryClause`, AGL-3321).\n */\nexport function contactListColumns(\n options: ContactListColumnOptions,\n): GridColDef[] {\n const { memberName, siteName } = options\n const nowMs = options.nowMs ?? Date.now()\n return [\n {\n field: 'name',\n headerName: 'Contact',\n flex: 1.6,\n minWidth: 220,\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'name'),\n valueGetter: (_value, row: ContactRecord) =>\n String(row.name || row.email || ''),\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Stack sx={{ justifyContent: 'center', height: '100%', lineHeight: 1.25 }}>\n <Typography variant=\"body2\" sx={{ lineHeight: 1.25 }}>\n {row.name || row.email}\n </Typography>\n {row.name ? (\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ lineHeight: 1.25 }}\n noWrap\n >\n {row.email}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n {\n field: 'ownerUid',\n headerName: 'Owner',\n flex: 0.9,\n minWidth: 140,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.ownerUid ? memberName(row.ownerUid) : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.ownerUid ? (\n <Typography variant=\"body2\" noWrap>\n {memberName(row.ownerUid)}\n </Typography>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n {\n field: 'lifecycleStage',\n headerName: 'Stage',\n flex: 0.9,\n minWidth: 150,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.lifecycleStage ? CONTACT_LIFECYCLE_STAGE_LABELS[row.lifecycleStage] : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.lifecycleStage ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={CONTACT_LIFECYCLE_STAGE_LABELS[row.lifecycleStage]}\n />\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n /*\n * KNOWN BY (AGL-2630): the sites that have captured this person, which\n * is the cross-site fact the organization-level list exists to show —\n * one deduplicated row per person, and which of the org's sites know\n * them. `capturedByHostIds` is a top-level array precisely so the\n * question can be answered without opening a facet. Not filterable from\n * the panel: an `array-contains` on it would displace the scope clause\n * under a site, and a search or a facet filter at the org level (one\n * array clause per query, AGL-3321). A row that names no site predates attribution and says so,\n * rather than reading as \"every site\".\n */\n ...(siteName\n ? [\n {\n field: 'capturedByHostIds',\n headerName: 'Known by',\n flex: 1.1,\n minWidth: 200,\n sortable: false,\n filterable: false,\n valueGetter: (_value: unknown, row: ContactRecord) =>\n row.capturedByHostIds.map(siteName).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.capturedByHostIds.length ? (\n <Stack\n direction=\"row\"\n spacing={0.5}\n sx={{ alignItems: 'center', height: '100%', flexWrap: 'nowrap' }}\n >\n {row.capturedByHostIds.slice(0, 2).map((hostId) => (\n <Chip key={hostId} size=\"small\" label={siteName(hostId)} />\n ))}\n {row.capturedByHostIds.length > 2 ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`+${row.capturedByHostIds.length - 2}`}\n />\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No site recorded'}\n </Typography>\n ),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'sources',\n headerName: 'Sources',\n flex: 1,\n minWidth: 160,\n // A map of provenance flags, not a scalar — `sources.form == true` is\n // queryable one key at a time, which is a menu of its own rather than\n // a filter on this column.\n filterable: false,\n sortable: false,\n valueGetter: (_value, row: ContactRecord) =>\n Object.keys(row.sources ?? {}).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Stack\n direction=\"row\"\n spacing={0.5}\n sx={{ alignItems: 'center', height: '100%' }}\n >\n {Object.keys(row.sources ?? {}).map((source) => (\n <Chip\n key={source}\n label={CONTACT_SOURCE_LABELS[source as ContactSource] ?? source}\n size=\"small\"\n />\n ))}\n </Stack>\n ),\n },\n {\n field: 'tags',\n headerName: 'Tags',\n flex: 1,\n minWidth: 150,\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'tags'),\n sortable: false,\n valueGetter: (_value, row: ContactRecord) => (row.tags ?? []).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) =>\n (row.tags ?? []).slice(0, 3).join(', '),\n },\n {\n field: 'updatedAt',\n headerName: 'Last activity',\n flex: 0.8,\n minWidth: 150,\n // `type: 'date'` is what gives the panel a date PICKER rather than a\n // free-text box for a value the query reads as a day.\n type: 'date',\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'updatedAt'),\n valueGetter: (_value, row: ContactRecord) => {\n const seconds = (row.updatedAt as { seconds?: number } | undefined)?.seconds\n return seconds ? new Date(seconds * 1000) : null\n },\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {row.interactions?.[0]\n ? new Date(row.interactions[0].atMs).toLocaleDateString()\n : '—'}\n </Typography>\n ),\n },\n // When the earliest open task against the person is due (AGL-2661).\n nextActivityColumn(nowMs),\n {\n /*\n * When the person last opened or clicked one of this holder's\n * campaigns (AGL-2616). Off the facet like Owner and Stage, so it can\n * never show another holder's readers; `filterable: false` for the\n * same reason theirs are — a facet path is not a query the panel can\n * make. A date column, so a sort on it orders by the instant and an\n * export gets a date; the cell prints the relative form the timeline\n * uses and carries the full stamp in its tooltip.\n */\n field: 'lastEmailEngagementAtMs',\n headerName: 'Last engaged',\n flex: 0.8,\n minWidth: 140,\n type: 'date',\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.lastEmailEngagementAtMs ? new Date(row.lastEmailEngagementAtMs) : null,\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.lastEmailEngagementAtMs ? (\n <Tooltip title={new Date(row.lastEmailEngagementAtMs).toLocaleString()}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {activityTimeLabel(row.lastEmailEngagementAtMs, nowMs)}\n </Typography>\n </Tooltip>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n {\n /*\n * The verdict on the address (AGL-3245): the same chip the record page\n * carries, so a bounced or blocked person is told apart in the list a\n * member picks recipients from. Sorted and exported by its label;\n * filtered through the bar's `emailState` clause, not the panel.\n */\n field: 'emailState',\n headerName: 'Email',\n flex: 0.8,\n minWidth: 140,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.emailState ? EMAIL_STATE_LABELS[row.emailState.status] : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.emailState ? (\n <CrmEmailStateChip state={row.emailState} />\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n ...hiddenFilterColumns(\n CONTACT_LIST_FILTER_FIELDS,\n CONTACT_FILTER_COLUMNS,\n CONTACT_LIST_FILTER_HEADERS,\n ),\n ]\n}\n"],"names":["activityTimeLabel","CONTACT_LIFECYCLE_STAGE_LABELS","CONTACT_SOURCE_LABELS","EMAIL_STATE_LABELS","hiddenFilterColumns","listFilterColumn","Chip","Stack","Tooltip","Typography","CONTACT_LIST_FILTER_FIELDS","CONTACT_LIST_FILTER_HEADERS","CrmEmailStateChip","nextActivityColumn","CONTACT_FILTER_COLUMNS","CONTACT_OPTIONAL_COLUMNS","contactListColumns","options","memberName","siteName","nowMs","Date","now","field","headerName","flex","minWidth","valueGetter","_value","row","String","name","email","renderCell","sx","justifyContent","height","lineHeight","variant","color","noWrap","filterable","ownerUid","lifecycleStage","size","label","sortable","capturedByHostIds","map","join","length","direction","spacing","alignItems","flexWrap","slice","hostId","Object","keys","sources","source","tags","type","seconds","updatedAt","interactions","atMs","toLocaleDateString","lastEmailEngagementAtMs","title","toLocaleString","emailState","status","state"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-list-columns.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n activityTimeLabel,\n CONTACT_LIFECYCLE_STAGE_LABELS,\n CONTACT_SOURCE_LABELS,\n EMAIL_STATE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn'\nimport {\n hiddenFilterColumns,\n listFilterColumn,\n} from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { Chip, Stack, Tooltip, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n CONTACT_LIST_FILTER_FIELDS,\n CONTACT_LIST_FILTER_HEADERS,\n} from '../constants/contact-filters'\nimport type { ContactRecord } from '../model/contact-record'\nimport type { CrmShareChip } from '../model/crm-sharing'\nimport { CrmEmailStateChip } from './crm-email-state-chip'\nimport { nextActivityColumn } from './crm-next-activity-column'\n\n/**\n * The filterable fields that get a column. The rest of\n * `CONTACT_LIST_FILTER_FIELDS` still reaches the filter panel, hidden.\n */\n/**\n * The declared filter fields that ARE table columns, so the hidden\n * filter-only columns are built for the others alone. Owner and Stage\n * joined it when the grammar learned them (AGL-2617): each is a shown\n * column already, and a second, hidden one under the same field would be\n * a duplicate the grid refuses.\n */\nexport const CONTACT_FILTER_COLUMNS = [\n 'name',\n 'ownerUid',\n 'lifecycleStage',\n 'sources',\n 'tags',\n 'updatedAt',\n 'nextTaskAtMs',\n 'emailState',\n]\n\n/**\n * The columns that ship HIDDEN, for the grid's visibility model (AGL-2616).\n *\n * \"Last engaged\" is a fact most lists do not need on screen and every list\n * can turn on from the column menu; shipping it visible would widen a\n * table that already carries seven columns for a figure only a marketing\n * reader scans.\n */\nexport const CONTACT_OPTIONAL_COLUMNS = ['lastEmailEngagementAtMs']\n\nexport interface ContactListColumnOptions {\n /** The owner's name for a uid — the roster's, or the uid itself. */\n memberName: (uid: string) => string\n /**\n * How a site reads, by document id — handed in at the ORGANIZATION level\n * only (AGL-2630), where it turns on the \"Known by\" column: the sites\n * that have captured each person. Absent under a site, where every row\n * is known by the site and the column would say so on every line.\n */\n siteName?: (hostId: string) => string\n /** The words of a row's \"Shared by…\" chip (AGL-3336); absent draws none. */\n shareChipLabel?: (chip: CrmShareChip) => string\n /**\n * One clock for every row of one paint, so two contacts engaged a second\n * apart cannot read \"just now\" and \"1 min ago\". Defaults to the paint's\n * own `Date.now()`.\n */\n nowMs?: number\n}\n\n/**\n * The contacts list's columns (AGL-2596), in their own module so the list\n * file stays about the QUERY and the toolbar while other surfaces — a bulk\n * bar, an import — grow beside it.\n *\n * Owner and Stage read off the flattened row, which is already this\n * holder's facet (`contactRecordFromDoc`), so neither column can show\n * another holder's assignment. Filtering by either is the list's: the panel\n * names the field and the list asks the holder's facet key of its query\n * (`contactQueryClause`, AGL-3321).\n */\nexport function contactListColumns(\n options: ContactListColumnOptions,\n): GridColDef[] {\n const { memberName, siteName, shareChipLabel } = options\n const nowMs = options.nowMs ?? Date.now()\n return [\n {\n field: 'name',\n headerName: 'Contact',\n flex: 1.6,\n minWidth: 220,\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'name'),\n valueGetter: (_value, row: ContactRecord) =>\n String(row.name || row.email || ''),\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Stack sx={{ justifyContent: 'center', height: '100%', lineHeight: 1.25 }}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', minWidth: 0 }}>\n <Typography variant=\"body2\" sx={{ lineHeight: 1.25 }}>\n {row.name || row.email}\n </Typography>\n {/* Seen on this site only through a share (AGL-3336). */}\n {row.shareChip && shareChipLabel ? (\n <Chip size=\"small\" variant=\"outlined\" color=\"info\" label={shareChipLabel(row.shareChip)} />\n ) : null}\n </Stack>\n {row.name ? (\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ lineHeight: 1.25 }}\n noWrap\n >\n {row.email}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n {\n field: 'ownerUid',\n headerName: 'Owner',\n flex: 0.9,\n minWidth: 140,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.ownerUid ? memberName(row.ownerUid) : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.ownerUid ? (\n <Typography variant=\"body2\" noWrap>\n {memberName(row.ownerUid)}\n </Typography>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n {\n field: 'lifecycleStage',\n headerName: 'Stage',\n flex: 0.9,\n minWidth: 150,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.lifecycleStage ? CONTACT_LIFECYCLE_STAGE_LABELS[row.lifecycleStage] : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.lifecycleStage ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={CONTACT_LIFECYCLE_STAGE_LABELS[row.lifecycleStage]}\n />\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n /*\n * KNOWN BY (AGL-2630): the sites that have captured this person, which\n * is the cross-site fact the organization-level list exists to show —\n * one deduplicated row per person, and which of the org's sites know\n * them. `capturedByHostIds` is a top-level array precisely so the\n * question can be answered without opening a facet. Not filterable from\n * the panel: an `array-contains` on it would displace the scope clause\n * under a site, and a search or a facet filter at the org level (one\n * array clause per query, AGL-3321). A row that names no site predates attribution and says so,\n * rather than reading as \"every site\".\n */\n ...(siteName\n ? [\n {\n field: 'capturedByHostIds',\n headerName: 'Known by',\n flex: 1.1,\n minWidth: 200,\n sortable: false,\n filterable: false,\n valueGetter: (_value: unknown, row: ContactRecord) =>\n row.capturedByHostIds.map(siteName).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.capturedByHostIds.length ? (\n <Stack\n direction=\"row\"\n spacing={0.5}\n sx={{ alignItems: 'center', height: '100%', flexWrap: 'nowrap' }}\n >\n {row.capturedByHostIds.slice(0, 2).map((hostId) => (\n <Chip key={hostId} size=\"small\" label={siteName(hostId)} />\n ))}\n {row.capturedByHostIds.length > 2 ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`+${row.capturedByHostIds.length - 2}`}\n />\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No site recorded'}\n </Typography>\n ),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'sources',\n headerName: 'Sources',\n flex: 1,\n minWidth: 160,\n // A map of provenance flags, not a scalar — `sources.form == true` is\n // queryable one key at a time, which is a menu of its own rather than\n // a filter on this column.\n filterable: false,\n sortable: false,\n valueGetter: (_value, row: ContactRecord) =>\n Object.keys(row.sources ?? {}).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Stack\n direction=\"row\"\n spacing={0.5}\n sx={{ alignItems: 'center', height: '100%' }}\n >\n {Object.keys(row.sources ?? {}).map((source) => (\n <Chip\n key={source}\n label={CONTACT_SOURCE_LABELS[source as ContactSource] ?? source}\n size=\"small\"\n />\n ))}\n </Stack>\n ),\n },\n {\n field: 'tags',\n headerName: 'Tags',\n flex: 1,\n minWidth: 150,\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'tags'),\n sortable: false,\n valueGetter: (_value, row: ContactRecord) => (row.tags ?? []).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) =>\n (row.tags ?? []).slice(0, 3).join(', '),\n },\n {\n field: 'updatedAt',\n headerName: 'Last activity',\n flex: 0.8,\n minWidth: 150,\n // `type: 'date'` is what gives the panel a date PICKER rather than a\n // free-text box for a value the query reads as a day.\n type: 'date',\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'updatedAt'),\n valueGetter: (_value, row: ContactRecord) => {\n const seconds = (row.updatedAt as { seconds?: number } | undefined)?.seconds\n return seconds ? new Date(seconds * 1000) : null\n },\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {row.interactions?.[0]\n ? new Date(row.interactions[0].atMs).toLocaleDateString()\n : '—'}\n </Typography>\n ),\n },\n // When the earliest open task against the person is due (AGL-2661).\n nextActivityColumn(nowMs),\n {\n /*\n * When the person last opened or clicked one of this holder's\n * campaigns (AGL-2616). Off the facet like Owner and Stage, so it can\n * never show another holder's readers; `filterable: false` for the\n * same reason theirs are — a facet path is not a query the panel can\n * make. A date column, so a sort on it orders by the instant and an\n * export gets a date; the cell prints the relative form the timeline\n * uses and carries the full stamp in its tooltip.\n */\n field: 'lastEmailEngagementAtMs',\n headerName: 'Last engaged',\n flex: 0.8,\n minWidth: 140,\n type: 'date',\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.lastEmailEngagementAtMs ? new Date(row.lastEmailEngagementAtMs) : null,\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.lastEmailEngagementAtMs ? (\n <Tooltip title={new Date(row.lastEmailEngagementAtMs).toLocaleString()}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {activityTimeLabel(row.lastEmailEngagementAtMs, nowMs)}\n </Typography>\n </Tooltip>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n {\n /*\n * The verdict on the address (AGL-3245): the same chip the record page\n * carries, so a bounced or blocked person is told apart in the list a\n * member picks recipients from. Sorted and exported by its label;\n * filtered through the bar's `emailState` clause, not the panel.\n */\n field: 'emailState',\n headerName: 'Email',\n flex: 0.8,\n minWidth: 140,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.emailState ? EMAIL_STATE_LABELS[row.emailState.status] : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.emailState ? (\n <CrmEmailStateChip state={row.emailState} />\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n ...hiddenFilterColumns(\n CONTACT_LIST_FILTER_FIELDS,\n CONTACT_FILTER_COLUMNS,\n CONTACT_LIST_FILTER_HEADERS,\n ),\n ]\n}\n"],"names":["activityTimeLabel","CONTACT_LIFECYCLE_STAGE_LABELS","CONTACT_SOURCE_LABELS","EMAIL_STATE_LABELS","hiddenFilterColumns","listFilterColumn","Chip","Stack","Tooltip","Typography","CONTACT_LIST_FILTER_FIELDS","CONTACT_LIST_FILTER_HEADERS","CrmEmailStateChip","nextActivityColumn","CONTACT_FILTER_COLUMNS","CONTACT_OPTIONAL_COLUMNS","contactListColumns","options","memberName","siteName","shareChipLabel","nowMs","Date","now","field","headerName","flex","minWidth","valueGetter","_value","row","String","name","email","renderCell","sx","justifyContent","height","lineHeight","direction","spacing","alignItems","variant","shareChip","size","color","label","noWrap","filterable","ownerUid","lifecycleStage","sortable","capturedByHostIds","map","join","length","flexWrap","slice","hostId","Object","keys","sources","source","tags","type","seconds","updatedAt","interactions","atMs","toLocaleDateString","lastEmailEngagementAtMs","title","toLocaleString","emailState","status","state"],"mappings":"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"}
@@ -16,10 +16,11 @@
16
16
  */ 'use client';
17
17
  import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
- import { contactDisplayName, contactMergePreview, nameSearchKey, normalizeContactEmail } from "@aglyn/aglyn";
19
+ import { contactDisplayName, nameSearchKey, normalizeContactEmail } from "@aglyn/aglyn";
20
20
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
21
21
  import { useFirestore } from "@aglyn/tenant-feature-instance";
22
22
  import { ScrollTable } from "@aglyn/shared-ui-jsx/components/scroll-table.component";
23
+ import { contactMergePreview } from "../model/contact-merge.js";
23
24
  import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, FormControlLabel, FormLabel, List, ListItemButton, ListItemText, Radio, RadioGroup, Stack, TableBody, TableCell, TableHead, TableRow, TextField, Typography } from "@mui/material";
24
25
  import { collection, endAt, getDocs, limit, orderBy, query, startAt, where } from "firebase/firestore";
25
26
  import { useCallback, useEffect, useMemo, useState } from "react";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-merge-dialog.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n type ConsentGroup,\n type ContactMergePreviewRow,\n contactDisplayName,\n contactMergePreview,\n nameSearchKey,\n normalizeContactEmail,\n} from '@aglyn/aglyn'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport {\n Alert,\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n FormControl,\n FormControlLabel,\n FormLabel,\n List,\n ListItemButton,\n ListItemText,\n Radio,\n RadioGroup,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n TextField,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n endAt,\n getDocs,\n limit,\n orderBy,\n query,\n startAt,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport { crmVisibleToClause } from '../hooks/use-crm-scope'\nimport { useCrmApi } from './use-crm-api'\n\n/** One contact as the dialog holds it: its id and the document as read. */\nexport interface ContactPick {\n id: string\n doc: Record<string, unknown>\n}\n\n/** Which of the two records stays. */\nexport type ContactMergeKeep = 'current' | 'other'\n\n/** How many rows a search answers — a picker, not a list. */\nconst SEARCH_LIMIT = 8\n/** The high code point that closes a `nameLower` prefix range. */\nconst HIGH = ''\n\nexport interface ContactMergeDialogState {\n open: boolean\n /** The record to merge with, when the opener already knows it. */\n other: ContactPick | null\n keep: ContactMergeKeep\n}\n\n/**\n * The dialog's open state, held by the page so two openers — the record's\n * overflow menu and the likely-duplicates card — share one dialog.\n *\n * The menu opens it empty, to be searched, with the OTHER record surviving:\n * \"merge this into…\" is what a reader means when they are on the record\n * they want gone. The duplicates card opens it with the candidate already\n * picked and THIS record surviving: the reader is on the record they keep,\n * looking at the copies. Either way the dialog lets them switch.\n */\nexport function useContactMergeDialog() {\n const [state, setState] = useState<ContactMergeDialogState>({\n open: false,\n other: null,\n keep: 'other',\n })\n const open = useCallback(\n (options: { other?: ContactPick; keep?: ContactMergeKeep } = {}) =>\n setState({\n open: true,\n other: options.other ?? null,\n keep: options.keep ?? (options.other ? 'current' : 'other'),\n }),\n [],\n )\n const close = useCallback(\n () => setState((previous) => ({ ...previous, open: false })),\n [],\n )\n return { state, open, close }\n}\n\nexport interface ContactMergeDialogProps extends ContactMergeDialogState {\n onClose: () => void\n /**\n * The site the merge runs as — the route logs it on that site's feed. At\n * the organization level the record's own capturing site (AGL-2630), or\n * `null` for a row no site has captured: beneath the org hub's mount the\n * route's org variant merges it anyway (AGL-2634), and only a surface\n * mounted nowhere holds the button.\n */\n hostId: string | null\n /** The record whose page this is. */\n current: ContactPick\n /** `['orgs', orgId]` — where the contacts live. */\n scope: readonly [string, string]\n consentGroup: ConsentGroup\n /**\n * What this viewer may list — the search's `array-contains-any` — or\n * `null` at the organization level, where the search carries no clause\n * (AGL-2630).\n */\n visibleTo: readonly string[] | null\n /** The owner's name for a uid, for the preview. */\n memberName?: (uid: string) => string\n /** Told which record survived, once the server has merged. */\n onMerged?: (survivorId: string) => void\n}\n\nconst label = (pick: ContactPick, groupId: string) =>\n contactDisplayName(pick.doc, groupId) || String(pick.doc['email'] ?? pick.id)\n\n/**\n * MERGE TWO RECORDS FOR ONE PERSON (AGL-2625).\n *\n * Two steps in one dialog. First the OTHER record, found by address or by\n * name among the contacts this viewer may list — the same two searches the\n * company's contacts card runs, one read each, on ask. Then the two records\n * side by side, field by field, with the value the surviving record will\n * carry and where it came from — `contactMergePreview` is the same rule the\n * server writes with, so what the reader is shown is what lands.\n *\n * The reader picks which record survives. The survivor keeps its address;\n * the other record's address becomes an alternate on it, its deals, tasks,\n * activities and leads move, and the document is deleted. The act is one\n * server route, because it is one transaction over two documents and a\n * repoint of rows a browser may not be able to list.\n */\nexport function ContactMergeDialog(props: ContactMergeDialogProps) {\n const {\n open,\n onClose,\n hostId,\n current,\n scope,\n consentGroup,\n visibleTo,\n memberName,\n onMerged,\n } = props\n const firestore = useFirestore()\n const callCrm = useCrmApi(hostId)\n const { enqueueSnackbar } = useSnackbar()\n const groupId = consentGroup.groupId\n const orgMount = useCrmOrgMount()\n // A site to run as, or the org to run as (AGL-2634): one of the two is\n // what the route authorizes against, and with neither there is no door.\n const canMerge = Boolean(hostId) || Boolean(orgMount)\n\n const [other, setOther] = useState<ContactPick | null>(props.other)\n const [keep, setKeep] = useState<ContactMergeKeep>(props.keep)\n const [term, setTerm] = useState('')\n const [searching, setSearching] = useState(false)\n const [results, setResults] = useState<ContactPick[] | null>(null)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n // Reset on every open to what the opener handed over.\n useEffect(() => {\n if (!open) return\n setOther(props.other)\n setKeep(props.keep)\n setTerm('')\n setResults(null)\n setError(null)\n setBusy(false)\n }, [open, props.other, props.keep])\n\n const search = useCallback(async () => {\n const raw = term.trim()\n if (!raw || searching) return\n setSearching(true)\n setError(null)\n try {\n const contactsRef = collection(firestore, scope[0], scope[1], 'contacts')\n const scoped = crmVisibleToClause(visibleTo)\n let found\n if (raw.includes('@')) {\n const email = normalizeContactEmail(raw)\n if (!email) {\n setResults([])\n return\n }\n found = await getDocs(\n query(contactsRef, ...scoped, where('email', '==', email), limit(SEARCH_LIMIT)),\n )\n } else {\n const key = nameSearchKey(raw)\n found = await getDocs(\n query(\n contactsRef,\n ...scoped,\n orderBy('nameLower'),\n startAt(key),\n endAt(`${key}${HIGH}`),\n limit(SEARCH_LIMIT),\n ),\n )\n }\n setResults(\n found.docs\n .filter((snapshot) => snapshot.id !== current.id)\n .map((snapshot) => ({ id: snapshot.id, doc: snapshot.data() })),\n )\n } catch (searchError) {\n console.error(searchError)\n setResults(null)\n setError('The search could not be run.')\n } finally {\n setSearching(false)\n }\n }, [term, searching, firestore, scope, visibleTo, current.id])\n\n const survivor = keep === 'current' ? current : other\n const merged = keep === 'current' ? other : current\n const rows: ContactMergePreviewRow[] = useMemo(\n () =>\n survivor && merged\n ? contactMergePreview(survivor.doc, merged.doc, groupId, { memberName })\n : [],\n [survivor, merged, groupId, memberName],\n )\n\n const submit = useCallback(async () => {\n if (!survivor || !merged || busy || !canMerge) return\n setBusy(true)\n setError(null)\n try {\n const { response, payload } = await callCrm('contacts-merge', {\n survivorId: survivor.id,\n mergedId: merged.id,\n })\n if (!response.ok) {\n setError(String(payload['error'] ?? 'The contacts could not be merged.'))\n return\n }\n enqueueSnackbar('Contacts merged', { variant: 'success', persist: false })\n onClose()\n onMerged?.(survivor.id)\n } catch (submitError) {\n console.error(submitError)\n setError('The contacts could not be merged.')\n } finally {\n setBusy(false)\n }\n }, [survivor, merged, busy, canMerge, callCrm, enqueueSnackbar, onClose, onMerged])\n\n const currentLabel = label(current, groupId)\n const otherLabel = other ? label(other, groupId) : ''\n\n return (\n <Dialog open={open} onClose={busy ? undefined : onClose} maxWidth=\"md\" fullWidth>\n <DialogTitle>{'Merge contacts'}</DialogTitle>\n <DialogContent>\n <Stack spacing={2} sx={{ pt: 1 }}>\n {!other ? (\n <>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`Find the other record for ${currentLabel} — by email address, or by name.`}\n </Typography>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'flex-start' }}>\n <TextField\n label=\"Email or name\"\n value={term}\n onChange={(event) => setTerm(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === 'Enter') {\n event.preventDefault()\n void search()\n }\n }}\n size=\"small\"\n fullWidth\n autoFocus\n />\n <Button\n variant=\"outlined\"\n onClick={() => void search()}\n disabled={!term.trim() || searching}\n >\n {searching ? 'Searching…' : 'Search'}\n </Button>\n </Stack>\n {results ? (\n results.length ? (\n <List dense disablePadding aria-label=\"Matching contacts\">\n {results.map((pick) => (\n <ListItemButton key={pick.id} onClick={() => setOther(pick)}>\n <ListItemText\n primary={label(pick, groupId)}\n secondary={String(pick.doc['email'] ?? '')}\n />\n </ListItemButton>\n ))}\n </List>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No other contact matches among the ones you can see.'}\n </Typography>\n )\n ) : null}\n </>\n ) : (\n <>\n <FormControl>\n <FormLabel id=\"contact-merge-keep\">{'Keep'}</FormLabel>\n <RadioGroup\n aria-labelledby=\"contact-merge-keep\"\n value={keep}\n onChange={(event) => setKeep(event.target.value as ContactMergeKeep)}\n >\n <FormControlLabel\n value=\"current\"\n control={<Radio size=\"small\" />}\n label={`${currentLabel} (${String(current.doc['email'] ?? '')})`}\n />\n <FormControlLabel\n value=\"other\"\n control={<Radio size=\"small\" />}\n label={`${otherLabel} (${String(other.doc['email'] ?? '')})`}\n />\n </RadioGroup>\n </FormControl>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The kept record keeps every value it has; empty fields fill from the ' +\n 'other record. Tags, filings and the timeline are combined, and the ' +\n \"other record's deals, tasks, activities and leads move across. Its \" +\n 'address becomes an alternate on the kept record, so a later capture ' +\n 'on it lands here — and the other record is deleted.'}\n </Typography>\n <ScrollTable size=\"small\" aria-label=\"What the merge keeps\">\n <TableHead>\n <TableRow>\n <TableCell>{'Field'}</TableCell>\n <TableCell>{'Kept'}</TableCell>\n <TableCell>{'Merged'}</TableCell>\n <TableCell>{'After'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.key}>\n <TableCell component=\"th\" scope=\"row\">\n {row.label}\n </TableCell>\n <TableCell sx={{ whiteSpace: 'pre-wrap' }}>{row.survivor}</TableCell>\n <TableCell sx={{ whiteSpace: 'pre-wrap' }}>{row.merged}</TableCell>\n <TableCell\n sx={{\n whiteSpace: 'pre-wrap',\n fontWeight: row.from === 'merged' || row.from === 'both' ? 600 : undefined,\n }}\n >\n {row.result}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n {!props.other ? (\n <Button size=\"small\" onClick={() => setOther(null)} sx={{ alignSelf: 'flex-start' }}>\n {'Pick a different contact'}\n </Button>\n ) : null}\n </>\n )}\n {error ? <Alert severity=\"error\">{error}</Alert> : null}\n {!canMerge ? (\n <Alert severity=\"info\">\n {'No site has captured this contact, so there is no site to ' +\n 'merge it from. Open it from one of your sites instead.'}\n </Alert>\n ) : null}\n </Stack>\n </DialogContent>\n <DialogActions>\n <Button onClick={onClose} disabled={busy}>\n {'Cancel'}\n </Button>\n <Button\n variant=\"contained\"\n onClick={() => void submit()}\n disabled={!other || busy || !canMerge}\n >\n {busy ? 'Merging…' : 'Merge'}\n </Button>\n </DialogActions>\n </Dialog>\n )\n}\nContactMergeDialog.displayName = 'ContactMergeDialog'\n\nexport default ContactMergeDialog\n"],"names":["contactDisplayName","contactMergePreview","nameSearchKey","normalizeContactEmail","useSnackbar","useFirestore","ScrollTable","Alert","Button","Dialog","DialogActions","DialogContent","DialogTitle","FormControl","FormControlLabel","FormLabel","List","ListItemButton","ListItemText","Radio","RadioGroup","Stack","TableBody","TableCell","TableHead","TableRow","TextField","Typography","collection","endAt","getDocs","limit","orderBy","query","startAt","where","useCallback","useEffect","useMemo","useState","useCrmOrgMount","crmVisibleToClause","useCrmApi","SEARCH_LIMIT","HIGH","useContactMergeDialog","state","setState","open","other","keep","options","close","previous","label","pick","groupId","doc","String","id","ContactMergeDialog","props","current","onClose","hostId","scope","consentGroup","visibleTo","memberName","onMerged","firestore","callCrm","enqueueSnackbar","orgMount","canMerge","Boolean","setOther","setKeep","term","setTerm","searching","setSearching","results","setResults","busy","setBusy","error","setError","search","raw","trim","contactsRef","scoped","found","includes","email","key","docs","filter","snapshot","map","data","searchError","console","survivor","merged","rows","submit","response","payload","survivorId","mergedId","ok","variant","persist","submitError","currentLabel","otherLabel","undefined","maxWidth","fullWidth","spacing","sx","pt","color","direction","alignItems","value","onChange","event","target","onKeyDown","preventDefault","size","autoFocus","onClick","disabled","length","dense","disablePadding","aria-label","primary","secondary","aria-labelledby","control","row","component","whiteSpace","fontWeight","from","result","alignSelf","severity","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-merge-dialog.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n type ConsentGroup,\n contactDisplayName,\n nameSearchKey,\n normalizeContactEmail,\n} from '@aglyn/aglyn'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport { type ContactMergePreviewRow, contactMergePreview } from '../model/contact-merge'\nimport {\n Alert,\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n FormControl,\n FormControlLabel,\n FormLabel,\n List,\n ListItemButton,\n ListItemText,\n Radio,\n RadioGroup,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n TextField,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n endAt,\n getDocs,\n limit,\n orderBy,\n query,\n startAt,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport { crmVisibleToClause } from '../hooks/use-crm-scope'\nimport { useCrmApi } from './use-crm-api'\n\n/** One contact as the dialog holds it: its id and the document as read. */\nexport interface ContactPick {\n id: string\n doc: Record<string, unknown>\n}\n\n/** Which of the two records stays. */\nexport type ContactMergeKeep = 'current' | 'other'\n\n/** How many rows a search answers — a picker, not a list. */\nconst SEARCH_LIMIT = 8\n/** The high code point that closes a `nameLower` prefix range. */\nconst HIGH = ''\n\nexport interface ContactMergeDialogState {\n open: boolean\n /** The record to merge with, when the opener already knows it. */\n other: ContactPick | null\n keep: ContactMergeKeep\n}\n\n/**\n * The dialog's open state, held by the page so two openers — the record's\n * overflow menu and the likely-duplicates card — share one dialog.\n *\n * The menu opens it empty, to be searched, with the OTHER record surviving:\n * \"merge this into…\" is what a reader means when they are on the record\n * they want gone. The duplicates card opens it with the candidate already\n * picked and THIS record surviving: the reader is on the record they keep,\n * looking at the copies. Either way the dialog lets them switch.\n */\nexport function useContactMergeDialog() {\n const [state, setState] = useState<ContactMergeDialogState>({\n open: false,\n other: null,\n keep: 'other',\n })\n const open = useCallback(\n (options: { other?: ContactPick; keep?: ContactMergeKeep } = {}) =>\n setState({\n open: true,\n other: options.other ?? null,\n keep: options.keep ?? (options.other ? 'current' : 'other'),\n }),\n [],\n )\n const close = useCallback(\n () => setState((previous) => ({ ...previous, open: false })),\n [],\n )\n return { state, open, close }\n}\n\nexport interface ContactMergeDialogProps extends ContactMergeDialogState {\n onClose: () => void\n /**\n * The site the merge runs as — the route logs it on that site's feed. At\n * the organization level the record's own capturing site (AGL-2630), or\n * `null` for a row no site has captured: beneath the org hub's mount the\n * route's org variant merges it anyway (AGL-2634), and only a surface\n * mounted nowhere holds the button.\n */\n hostId: string | null\n /** The record whose page this is. */\n current: ContactPick\n /** `['orgs', orgId]` — where the contacts live. */\n scope: readonly [string, string]\n consentGroup: ConsentGroup\n /**\n * What this viewer may list — the search's `array-contains-any` — or\n * `null` at the organization level, where the search carries no clause\n * (AGL-2630).\n */\n visibleTo: readonly string[] | null\n /** The owner's name for a uid, for the preview. */\n memberName?: (uid: string) => string\n /** Told which record survived, once the server has merged. */\n onMerged?: (survivorId: string) => void\n}\n\nconst label = (pick: ContactPick, groupId: string) =>\n contactDisplayName(pick.doc, groupId) || String(pick.doc['email'] ?? pick.id)\n\n/**\n * MERGE TWO RECORDS FOR ONE PERSON (AGL-2625).\n *\n * Two steps in one dialog. First the OTHER record, found by address or by\n * name among the contacts this viewer may list — the same two searches the\n * company's contacts card runs, one read each, on ask. Then the two records\n * side by side, field by field, with the value the surviving record will\n * carry and where it came from — `contactMergePreview` is the same rule the\n * server writes with, so what the reader is shown is what lands.\n *\n * The reader picks which record survives. The survivor keeps its address;\n * the other record's address becomes an alternate on it, its deals, tasks,\n * activities and leads move, and the document is deleted. The act is one\n * server route, because it is one transaction over two documents and a\n * repoint of rows a browser may not be able to list.\n */\nexport function ContactMergeDialog(props: ContactMergeDialogProps) {\n const {\n open,\n onClose,\n hostId,\n current,\n scope,\n consentGroup,\n visibleTo,\n memberName,\n onMerged,\n } = props\n const firestore = useFirestore()\n const callCrm = useCrmApi(hostId)\n const { enqueueSnackbar } = useSnackbar()\n const groupId = consentGroup.groupId\n const orgMount = useCrmOrgMount()\n // A site to run as, or the org to run as (AGL-2634): one of the two is\n // what the route authorizes against, and with neither there is no door.\n const canMerge = Boolean(hostId) || Boolean(orgMount)\n\n const [other, setOther] = useState<ContactPick | null>(props.other)\n const [keep, setKeep] = useState<ContactMergeKeep>(props.keep)\n const [term, setTerm] = useState('')\n const [searching, setSearching] = useState(false)\n const [results, setResults] = useState<ContactPick[] | null>(null)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n // Reset on every open to what the opener handed over.\n useEffect(() => {\n if (!open) return\n setOther(props.other)\n setKeep(props.keep)\n setTerm('')\n setResults(null)\n setError(null)\n setBusy(false)\n }, [open, props.other, props.keep])\n\n const search = useCallback(async () => {\n const raw = term.trim()\n if (!raw || searching) return\n setSearching(true)\n setError(null)\n try {\n const contactsRef = collection(firestore, scope[0], scope[1], 'contacts')\n const scoped = crmVisibleToClause(visibleTo)\n let found\n if (raw.includes('@')) {\n const email = normalizeContactEmail(raw)\n if (!email) {\n setResults([])\n return\n }\n found = await getDocs(\n query(contactsRef, ...scoped, where('email', '==', email), limit(SEARCH_LIMIT)),\n )\n } else {\n const key = nameSearchKey(raw)\n found = await getDocs(\n query(\n contactsRef,\n ...scoped,\n orderBy('nameLower'),\n startAt(key),\n endAt(`${key}${HIGH}`),\n limit(SEARCH_LIMIT),\n ),\n )\n }\n setResults(\n found.docs\n .filter((snapshot) => snapshot.id !== current.id)\n .map((snapshot) => ({ id: snapshot.id, doc: snapshot.data() })),\n )\n } catch (searchError) {\n console.error(searchError)\n setResults(null)\n setError('The search could not be run.')\n } finally {\n setSearching(false)\n }\n }, [term, searching, firestore, scope, visibleTo, current.id])\n\n const survivor = keep === 'current' ? current : other\n const merged = keep === 'current' ? other : current\n const rows: ContactMergePreviewRow[] = useMemo(\n () =>\n survivor && merged\n ? contactMergePreview(survivor.doc, merged.doc, groupId, { memberName })\n : [],\n [survivor, merged, groupId, memberName],\n )\n\n const submit = useCallback(async () => {\n if (!survivor || !merged || busy || !canMerge) return\n setBusy(true)\n setError(null)\n try {\n const { response, payload } = await callCrm('contacts-merge', {\n survivorId: survivor.id,\n mergedId: merged.id,\n })\n if (!response.ok) {\n setError(String(payload['error'] ?? 'The contacts could not be merged.'))\n return\n }\n enqueueSnackbar('Contacts merged', { variant: 'success', persist: false })\n onClose()\n onMerged?.(survivor.id)\n } catch (submitError) {\n console.error(submitError)\n setError('The contacts could not be merged.')\n } finally {\n setBusy(false)\n }\n }, [survivor, merged, busy, canMerge, callCrm, enqueueSnackbar, onClose, onMerged])\n\n const currentLabel = label(current, groupId)\n const otherLabel = other ? label(other, groupId) : ''\n\n return (\n <Dialog open={open} onClose={busy ? undefined : onClose} maxWidth=\"md\" fullWidth>\n <DialogTitle>{'Merge contacts'}</DialogTitle>\n <DialogContent>\n <Stack spacing={2} sx={{ pt: 1 }}>\n {!other ? (\n <>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {`Find the other record for ${currentLabel} — by email address, or by name.`}\n </Typography>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'flex-start' }}>\n <TextField\n label=\"Email or name\"\n value={term}\n onChange={(event) => setTerm(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === 'Enter') {\n event.preventDefault()\n void search()\n }\n }}\n size=\"small\"\n fullWidth\n autoFocus\n />\n <Button\n variant=\"outlined\"\n onClick={() => void search()}\n disabled={!term.trim() || searching}\n >\n {searching ? 'Searching…' : 'Search'}\n </Button>\n </Stack>\n {results ? (\n results.length ? (\n <List dense disablePadding aria-label=\"Matching contacts\">\n {results.map((pick) => (\n <ListItemButton key={pick.id} onClick={() => setOther(pick)}>\n <ListItemText\n primary={label(pick, groupId)}\n secondary={String(pick.doc['email'] ?? '')}\n />\n </ListItemButton>\n ))}\n </List>\n ) : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No other contact matches among the ones you can see.'}\n </Typography>\n )\n ) : null}\n </>\n ) : (\n <>\n <FormControl>\n <FormLabel id=\"contact-merge-keep\">{'Keep'}</FormLabel>\n <RadioGroup\n aria-labelledby=\"contact-merge-keep\"\n value={keep}\n onChange={(event) => setKeep(event.target.value as ContactMergeKeep)}\n >\n <FormControlLabel\n value=\"current\"\n control={<Radio size=\"small\" />}\n label={`${currentLabel} (${String(current.doc['email'] ?? '')})`}\n />\n <FormControlLabel\n value=\"other\"\n control={<Radio size=\"small\" />}\n label={`${otherLabel} (${String(other.doc['email'] ?? '')})`}\n />\n </RadioGroup>\n </FormControl>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'The kept record keeps every value it has; empty fields fill from the ' +\n 'other record. Tags, filings and the timeline are combined, and the ' +\n \"other record's deals, tasks, activities and leads move across. Its \" +\n 'address becomes an alternate on the kept record, so a later capture ' +\n 'on it lands here — and the other record is deleted.'}\n </Typography>\n <ScrollTable size=\"small\" aria-label=\"What the merge keeps\">\n <TableHead>\n <TableRow>\n <TableCell>{'Field'}</TableCell>\n <TableCell>{'Kept'}</TableCell>\n <TableCell>{'Merged'}</TableCell>\n <TableCell>{'After'}</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {rows.map((row) => (\n <TableRow key={row.key}>\n <TableCell component=\"th\" scope=\"row\">\n {row.label}\n </TableCell>\n <TableCell sx={{ whiteSpace: 'pre-wrap' }}>{row.survivor}</TableCell>\n <TableCell sx={{ whiteSpace: 'pre-wrap' }}>{row.merged}</TableCell>\n <TableCell\n sx={{\n whiteSpace: 'pre-wrap',\n fontWeight: row.from === 'merged' || row.from === 'both' ? 600 : undefined,\n }}\n >\n {row.result}\n </TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n {!props.other ? (\n <Button size=\"small\" onClick={() => setOther(null)} sx={{ alignSelf: 'flex-start' }}>\n {'Pick a different contact'}\n </Button>\n ) : null}\n </>\n )}\n {error ? <Alert severity=\"error\">{error}</Alert> : null}\n {!canMerge ? (\n <Alert severity=\"info\">\n {'No site has captured this contact, so there is no site to ' +\n 'merge it from. Open it from one of your sites instead.'}\n </Alert>\n ) : null}\n </Stack>\n </DialogContent>\n <DialogActions>\n <Button onClick={onClose} disabled={busy}>\n {'Cancel'}\n </Button>\n <Button\n variant=\"contained\"\n onClick={() => void submit()}\n disabled={!other || busy || !canMerge}\n >\n {busy ? 'Merging…' : 'Merge'}\n </Button>\n </DialogActions>\n </Dialog>\n )\n}\nContactMergeDialog.displayName = 'ContactMergeDialog'\n\nexport default ContactMergeDialog\n"],"names":["contactDisplayName","nameSearchKey","normalizeContactEmail","useSnackbar","useFirestore","ScrollTable","contactMergePreview","Alert","Button","Dialog","DialogActions","DialogContent","DialogTitle","FormControl","FormControlLabel","FormLabel","List","ListItemButton","ListItemText","Radio","RadioGroup","Stack","TableBody","TableCell","TableHead","TableRow","TextField","Typography","collection","endAt","getDocs","limit","orderBy","query","startAt","where","useCallback","useEffect","useMemo","useState","useCrmOrgMount","crmVisibleToClause","useCrmApi","SEARCH_LIMIT","HIGH","useContactMergeDialog","state","setState","open","other","keep","options","close","previous","label","pick","groupId","doc","String","id","ContactMergeDialog","props","current","onClose","hostId","scope","consentGroup","visibleTo","memberName","onMerged","firestore","callCrm","enqueueSnackbar","orgMount","canMerge","Boolean","setOther","setKeep","term","setTerm","searching","setSearching","results","setResults","busy","setBusy","error","setError","search","raw","trim","contactsRef","scoped","found","includes","email","key","docs","filter","snapshot","map","data","searchError","console","survivor","merged","rows","submit","response","payload","survivorId","mergedId","ok","variant","persist","submitError","currentLabel","otherLabel","undefined","maxWidth","fullWidth","spacing","sx","pt","color","direction","alignItems","value","onChange","event","target","onKeyDown","preventDefault","size","autoFocus","onClick","disabled","length","dense","disablePadding","aria-label","primary","secondary","aria-labelledby","control","row","component","whiteSpace","fontWeight","from","result","alignSelf","severity","displayName"],"mappings":"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"}
@@ -25,6 +25,7 @@ import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.
25
25
  import { Button, Chip, Stack, Tooltip, Typography } from "@mui/material";
26
26
  import { useParams } from "next/navigation";
27
27
  import { useCallback, useMemo, useState } from "react";
28
+ import { contactInteractionHref, INTERACTION_LINK_LABELS } from "../model/contact-interaction-links.js";
28
29
  import { contactPrimaryGroup } from "../model/contact-record.js";
29
30
  import { ActivityRow } from "./activity-list.js";
30
31
  import { useActivityScope, useActivityWindow, useCanEditActivity } from "./activity-queries.js";
@@ -58,7 +59,7 @@ import { useContactCampaignEmails } from "./use-contact-campaign-emails.js";
58
59
  *
59
60
  * Everything it holds fits the collapsed row, so it has nothing to open.
60
61
  */ function CapturedRow(props) {
61
- var _CONTACT_SOURCE_LABELS_source, _interaction_summary, _Aglyn_INTERACTION_LINK_LABELS_source;
62
+ var _CONTACT_SOURCE_LABELS_source, _interaction_summary, _INTERACTION_LINK_LABELS_source;
62
63
  const { interaction, nowMs, href } = props;
63
64
  const source = interaction.type;
64
65
  const label = (_CONTACT_SOURCE_LABELS_source = CONTACT_SOURCE_LABELS[source]) != null ? _CONTACT_SOURCE_LABELS_source : String(source);
@@ -103,7 +104,7 @@ import { useContactCampaignEmails } from "./use-contact-campaign-emails.js";
103
104
  variant: "caption",
104
105
  children: /*#__PURE__*/ _jsx(AppLink, {
105
106
  href: href,
106
- children: (_Aglyn_INTERACTION_LINK_LABELS_source = Aglyn.INTERACTION_LINK_LABELS[source]) != null ? _Aglyn_INTERACTION_LINK_LABELS_source : 'Open'
107
+ children: (_INTERACTION_LINK_LABELS_source = INTERACTION_LINK_LABELS[source]) != null ? _INTERACTION_LINK_LABELS_source : 'Open'
107
108
  })
108
109
  }) : null
109
110
  ]
@@ -256,7 +257,7 @@ CampaignEmailRow.displayName = 'CampaignEmailRow';
256
257
  params == null ? void 0 : params.orgSlug,
257
258
  params == null ? void 0 : params.host
258
259
  ]);
259
- const interactionHref = useCallback((interaction)=>siteContext && interaction.hostId === hostId ? Aglyn.contactInteractionHref(interaction, siteContext) : null, [
260
+ const interactionHref = useCallback((interaction)=>siteContext && interaction.hostId === hostId ? contactInteractionHref(interaction, siteContext) : null, [
260
261
  siteContext,
261
262
  hostId
262
263
  ]);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-timeline-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport type {\n ContactCampaignEmail,\n ContactInteraction,\n ContactSource,\n CrmActivityRow,\n} from '@aglyn/aglyn'\nimport { CONTACT_SOURCE_LABELS, pluginDocsHelp } from '@aglyn/aglyn'\nimport {\n mdiAccountCircleOutline,\n mdiAccountEditOutline,\n mdiAccountPlusOutline,\n mdiApi,\n mdiBullhornOutline,\n mdiCalendarCheckOutline,\n mdiCartOutline,\n mdiEmailNewsletter,\n mdiFileImportOutline,\n mdiFormSelect,\n} from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport EmptyStateComponent from '@aglyn/shared-ui-jsx/components/empty-state.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { Button, Chip, Stack, Tooltip, Typography } from '@mui/material'\nimport { useParams } from 'next/navigation'\nimport { useCallback, useMemo, useState } from 'react'\nimport { contactPrimaryGroup } from '../model/contact-record'\nimport { ActivityRow } from './activity-list'\nimport {\n type ActivityRecordLink,\n type CrmOrg,\n useActivityScope,\n useActivityWindow,\n useCanEditActivity,\n} from './activity-queries'\nimport {\n TimelineEntry,\n TimelineExpandAllButton,\n useTimelineExpansion,\n useTimelinePages,\n} from './activity-timeline'\nimport { CrmSuiteLockedButton } from './crm-suite-lock'\nimport { LogActivityDialog } from './log-activity-dialog'\nimport { useContactCampaignEmails } from './use-contact-campaign-emails'\n\n/**\n * One glyph per capture door. Typed against the source union so a door added\n * to it cannot render as the placeholder glyph.\n */\nconst SOURCE_ICONS: Record<ContactSource, { path: string }> = {\n form: mdiFormSelect,\n member: mdiAccountPlusOutline,\n order: mdiCartOutline,\n booking: mdiCalendarCheckOutline,\n newsletter: mdiEmailNewsletter,\n api: mdiApi,\n manual: mdiAccountEditOutline,\n import: mdiFileImportOutline,\n account: mdiAccountCircleOutline,\n}\n\n/**\n * One thing the platform recorded: the door it came through, what the door\n * wrote, when, and the page the person was on.\n *\n * Read-only by nature — nobody logged it, so nobody edits it. The entry\n * point sits beside the timestamp rather than in the summary, the way the\n * contacts drawer draws it: the summary is the sentence the door wrote, the\n * path is a fact the door recorded, and a long path on the sentence's line\n * pushes out the thing the row is about.\n *\n * Everything it holds fits the collapsed row, so it has nothing to open.\n */\nfunction CapturedRow(props: {\n interaction: ContactInteraction\n nowMs: number\n /** The console page the door's record is read on, when it left one. */\n href: string | null\n}) {\n const { interaction, nowMs, href } = props\n const source = interaction.type\n const label = CONTACT_SOURCE_LABELS[source] ?? String(source)\n const icon = SOURCE_ICONS[source]\n const when = new Date(interaction.atMs)\n return (\n <TimelineEntry\n icon={<MdiIcon path={icon?.path} />}\n chips={\n <>\n <Chip label={label} size=\"small\" />\n <Chip label=\"Captured\" size=\"small\" variant=\"outlined\" />\n </>\n }\n title={interaction.summary ?? label}\n meta={\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'baseline', flexWrap: 'wrap', rowGap: 0.25 }}\n >\n <Tooltip title={when.toLocaleString()}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {interaction.path\n ? `${Aglyn.activityTimeLabel(interaction.atMs, nowMs)} · ${interaction.path}`\n : Aglyn.activityTimeLabel(interaction.atMs, nowMs)}\n </Typography>\n </Tooltip>\n {/*\n The record the door left, where the console reads it (AGL-2622):\n the submission in the Inbox reader, the order in its dialog. A door\n that left nothing to open — a newsletter opt-in, an import — has no\n link, and the caption is the whole entry.\n */}\n {href ? (\n <Typography variant=\"caption\">\n <AppLink href={href}>\n {Aglyn.INTERACTION_LINK_LABELS[source] ?? 'Open'}\n </AppLink>\n </Typography>\n ) : null}\n </Stack>\n }\n />\n )\n}\nCapturedRow.displayName = 'CapturedRow'\n\n/**\n * One campaign email the platform sent this person (AGL-2616): which email,\n * and what became of it — `sent · delivered · opened ×2 · clicked`.\n *\n * Read-only like a captured row, and drawn apart from BOTH the captured and\n * the logged rows: its glyph is the campaign's, never the envelope a logged\n * `email` activity carries, and its chips say \"Campaign\" and \"Sent\" where\n * the others say \"Captured\" and \"Logged\". The name links to the email's own\n * report when the page can address it; an email the team has deleted, or\n * one a sibling site sent, keeps its name and loses the link.\n *\n * The delivery line stays on the collapsed row beside the name, so it has\n * nothing to open.\n */\nfunction CampaignEmailRow(props: {\n email: ContactCampaignEmail\n href: string | null\n nowMs: number\n}) {\n const { email, href, nowMs } = props\n const name = email.campaignName ?? email.subject ?? 'Campaign email'\n const summary = Aglyn.campaignEmailSummary(email).join(' · ')\n const when = new Date(email.sentAtMs)\n return (\n <TimelineEntry\n icon={<MdiIcon path={mdiBullhornOutline.path} />}\n chips={\n <>\n <Chip label=\"Campaign\" size=\"small\" />\n <Chip label=\"Sent\" size=\"small\" variant=\"outlined\" />\n </>\n }\n title={\n <>\n {href ? <AppLink href={href}>{name}</AppLink> : name}\n {` · ${summary}`}\n </>\n }\n meta={\n <Tooltip title={when.toLocaleString()}>\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ alignSelf: 'flex-start' }}\n >\n {email.subject && email.subject !== name\n ? `${Aglyn.activityTimeLabel(email.sentAtMs, nowMs)} · ${email.subject}`\n : Aglyn.activityTimeLabel(email.sentAtMs, nowMs)}\n </Typography>\n </Tooltip>\n }\n />\n )\n}\nCampaignEmailRow.displayName = 'CampaignEmailRow'\n\nexport interface ContactTimelineCardProps {\n /** The site the record is read under, or `null` at the organization level. */\n hostId: string | null\n org: CrmOrg\n contactId: string\n /**\n * The RAW contact document, as the page's listener hands it back. The card\n * resolves the viewing group and reads that group's facet itself, so a\n * page cannot hand it another holder's timeline by flattening the row\n * through the wrong group.\n */\n contact: Record<string, unknown> | null | undefined\n /**\n * Where a campaign entry links to — the email's report — or `null` for\n * one the page cannot address. Handed in rather than built here because\n * the report lives on another surface whose path is the MOUNT's to know:\n * a site's record page reaches its own Emails hub, an org-level mount\n * holds the org's site list and can reach every site's. No builder means\n * no links, never a guessed one.\n */\n campaignHref?: (email: ContactCampaignEmail) => string | null\n /**\n * The org's plan lacks the CRM (AGL-2788), which the shell mounts no CRM\n * page for (AGL-2851). The history still draws; logging an activity stands\n * locked, and one already logged cannot be edited.\n */\n suiteLocked?: boolean\n}\n\n/**\n * A person's whole history in one newest-first stream (AGL-2600): what the\n * platform CAPTURED — a form, an order, a booking, a sign-up — what a person\n * LOGGED beside it — a call, an email, a meeting, a note — and, since\n * AGL-2616, the campaign mail the platform SENT them and what became of it.\n *\n * ## Why one stream\n *\n * The histories answer one question, \"what has happened with this person\",\n * and a page showing them as separate lists asks the reader to interleave\n * them by eye. The merge is `mergeContactTimeline`, pure and specced: newest\n * first, stable at a tie, an undated entry last, a campaign at the instant\n * it was sent.\n *\n * Each entry says which it is. A captured entry carries the door's label and\n * a \"Captured\" chip and offers no controls; a logged entry carries its kind\n * and a \"Logged\" chip, and its author may edit or delete it; a campaign\n * entry carries \"Campaign\" and \"Sent\", links to the email's report, and is\n * nobody's to edit.\n *\n * ## What is read\n *\n * The captured side comes off the contact document the page already holds\n * — THIS group's facet, filtered to the sites the group may see, the same\n * projection the contacts list makes — and costs no read. The logged side is\n * the bounded listener every activity surface uses, filtered to this\n * contact, a hundred at a time. The campaign side is one request to\n * `crm/contact-email-history`, which reads down this person's own delivery\n * log, capped, and never a campaign's recipients. The facet's history is\n * capped at fifty by the platform, so past the window the stream is the\n * logged side alone.\n *\n * ## How it reads\n *\n * Every entry is a collapsed row that opens in place, paged by the\n * console's footer over the merged stream; a page turned past the logged\n * window widens it by the next hundred. **Expand all** and **Log activity**\n * sit in the card header, and edit and delete stay on the entry they act on.\n */\nexport function ContactTimelineCard(props: ContactTimelineCardProps) {\n const { hostId, org, contactId, contact, campaignHref, suiteLocked = false } = props\n const scope = useActivityScope(hostId, org)\n /*\n * THIS holder's captured history, and only the visits this group may see.\n * A read off the top of the document, or of another group's facet, would\n * be another business's records — the disclosure the facet shape ended.\n * At the organization level (AGL-2630) the holder is the person's own\n * primary one, the group the record page reads the profile through.\n */\n const consentGroup = useMemo(\n () =>\n scope.consentGroup ??\n contactPrimaryGroup(contact, (org ?? null) as Record<string, unknown> | null),\n [scope.consentGroup, contact, org],\n )\n const interactions = useMemo(() => {\n const facet = Aglyn.readContactFacet(contact, consentGroup.groupId)\n return Aglyn.interactionsForGroup(facet.interactions, consentGroup.hostIds)\n }, [contact, consentGroup])\n const link = useMemo<ActivityRecordLink>(() => ({ contactId }), [contactId])\n const activities = useActivityWindow(scope, link)\n /*\n * Where a captured entry's record is READ (AGL-2622). The site console's\n * two route params are already in the URL this page is on, so the link is\n * built from them rather than from two document reads per timeline. Only\n * an interaction on THIS site links: a sibling site's order is that\n * site's record, read on that site's console, and a link built with this\n * site's segment would 404 or — worse — open the wrong site's row under\n * the same id. An interaction with no site recorded predates the stamp\n * and is left unlinked rather than guessed at. Off a site — the org-level\n * mount — there is no segment to build with, and nothing links.\n */\n const params = useParams<{ orgSlug?: string; host?: string }>()\n const siteContext = useMemo(\n () =>\n params?.orgSlug && params?.host\n ? { orgSlug: String(params.orgSlug), host: String(params.host) }\n : null,\n [params?.orgSlug, params?.host],\n )\n const interactionHref = useCallback(\n (interaction: ContactInteraction): string | null =>\n siteContext && interaction.hostId === hostId\n ? Aglyn.contactInteractionHref(interaction, siteContext)\n : null,\n [siteContext, hostId],\n )\n // The campaign history is asked of a site: the mounted one, or the holder's.\n const campaigns = useContactCampaignEmails(\n hostId ?? (consentGroup.hostId || null),\n contactId,\n )\n // One member read for the whole stream, however many rows it has.\n const canEdit = useCanEditActivity(scope.orgId)\n const entries = useMemo(\n () =>\n Aglyn.mergeContactTimeline(interactions, activities.rows, campaigns.emails),\n [interactions, activities.rows, campaigns.emails],\n )\n // One clock for every row of one paint, so two rows logged a second apart\n // cannot read \"just now\" and \"1 min ago\" from being rendered across a\n // minute boundary.\n const nowMs = Date.now()\n\n const [dialogOpen, setDialogOpen] = useState(false)\n const [editing, setEditing] = useState<CrmActivityRow | null>(null)\n const openNew = useCallback(() => {\n setEditing(null)\n setDialogOpen(true)\n }, [])\n const openEdit = useCallback((activity: CrmActivityRow) => {\n setEditing(activity)\n setDialogOpen(true)\n }, [])\n const close = useCallback(() => setDialogOpen(false), [])\n\n const loggedChip = useMemo(\n () => <Chip label=\"Logged\" size=\"small\" variant=\"outlined\" />,\n [],\n )\n const expansion = useTimelineExpansion()\n const pages = useTimelinePages(entries.length, {\n hasMore: activities.hasMore,\n showMore: activities.showMore,\n loading: activities.status === 'loading',\n })\n const logButton = suiteLocked ? (\n <CrmSuiteLockedButton variant=\"contained\" color=\"primary\">\n {'Log activity'}\n </CrmSuiteLockedButton>\n ) : (\n <Button\n size=\"small\"\n variant=\"contained\"\n color=\"primary\"\n onClick={openNew}\n disabled={!activities.ready}\n >\n {'Log activity'}\n </Button>\n )\n\n return (\n <>\n <CardDisplay\n header={'Timeline'}\n help={pluginDocsHelp('contactActivities', { anchor: '#four-kinds-of-history' })}\n HeaderProps={{\n action: (\n <Stack direction=\"row\" spacing={1}>\n <TimelineExpandAllButton expansion={expansion} disabled={!entries.length} />\n {logButton}\n </Stack>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {activities.status === 'error' ? (\n <Typography variant=\"body2\" color=\"error\">\n {'Logged activity could not be loaded; what follows is what the platform captured.'}\n </Typography>\n ) : null}\n {campaigns.status === 'error' || campaigns.lookupFailed ? (\n // \"Unknown\", not \"none\": an empty campaign history and one that\n // could not be read lead a reader to opposite conclusions.\n <Typography variant=\"body2\" color=\"error\">\n {'Campaign email could not be loaded; the stream below may be missing the mail this person was sent.'}\n </Typography>\n ) : null}\n {entries.length === 0 ? (\n campaigns.status === 'loading' ? (\n // The campaign history is one request on mount; an empty state\n // before it answers would read as \"none\" for a person who was mailed.\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Loading…'}\n </Typography>\n ) : (\n <EmptyStateComponent\n compact\n label={'No history yet'}\n description={'What this person does on the site, the campaigns they are sent, and what you log about them, shows here.'}\n action={logButton}\n />\n )\n ) : (\n pages.slice(entries).map((entry) =>\n entry.kind === 'captured' ? (\n <CapturedRow\n key={entry.key}\n interaction={entry.interaction}\n nowMs={nowMs}\n href={interactionHref(entry.interaction)}\n />\n ) : entry.kind === 'campaign' ? (\n <CampaignEmailRow\n key={entry.key}\n email={entry.email}\n href={campaignHref ? campaignHref(entry.email) : null}\n nowMs={nowMs}\n />\n ) : (\n <ActivityRow\n key={entry.key}\n activity={entry.activity}\n scope={scope}\n onEdit={openEdit}\n subject={loggedChip}\n nowMs={nowMs}\n editable={!suiteLocked && canEdit(entry.activity)}\n expanded={expansion.isExpanded(entry.key)}\n onToggle={() => expansion.toggle(entry.key)}\n />\n ),\n )\n )}\n {entries.length ? <ListPagination {...pages.footer} /> : null}\n </Stack>\n </CardDisplay>\n <LogActivityDialog\n open={dialogOpen}\n onClose={close}\n scope={scope}\n link={link}\n activity={editing}\n />\n </>\n )\n}\nContactTimelineCard.displayName = 'ContactTimelineCard'\n\nexport default ContactTimelineCard\n"],"names":["Aglyn","CONTACT_SOURCE_LABELS","pluginDocsHelp","mdiAccountCircleOutline","mdiAccountEditOutline","mdiAccountPlusOutline","mdiApi","mdiBullhornOutline","mdiCalendarCheckOutline","mdiCartOutline","mdiEmailNewsletter","mdiFileImportOutline","mdiFormSelect","AppLink","CardDisplay","MdiIcon","EmptyStateComponent","ListPagination","Button","Chip","Stack","Tooltip","Typography","useParams","useCallback","useMemo","useState","contactPrimaryGroup","ActivityRow","useActivityScope","useActivityWindow","useCanEditActivity","TimelineEntry","TimelineExpandAllButton","useTimelineExpansion","useTimelinePages","CrmSuiteLockedButton","LogActivityDialog","useContactCampaignEmails","SOURCE_ICONS","form","member","order","booking","newsletter","api","manual","import","account","CapturedRow","props","interaction","nowMs","href","source","type","label","String","icon","when","Date","atMs","path","chips","size","variant","title","summary","meta","direction","spacing","sx","alignItems","flexWrap","rowGap","toLocaleString","color","activityTimeLabel","INTERACTION_LINK_LABELS","displayName","CampaignEmailRow","email","name","campaignName","subject","campaignEmailSummary","join","sentAtMs","alignSelf","ContactTimelineCard","hostId","org","contactId","contact","campaignHref","suiteLocked","scope","consentGroup","interactions","facet","readContactFacet","groupId","interactionsForGroup","hostIds","link","activities","params","siteContext","orgSlug","host","interactionHref","contactInteractionHref","campaigns","canEdit","orgId","entries","mergeContactTimeline","rows","emails","now","dialogOpen","setDialogOpen","editing","setEditing","openNew","openEdit","activity","close","loggedChip","expansion","pages","length","hasMore","showMore","loading","status","logButton","onClick","disabled","ready","header","help","anchor","HeaderProps","action","contentGutterX","contentGutterY","lookupFailed","compact","description","slice","map","entry","kind","key","onEdit","editable","expanded","isExpanded","onToggle","toggle","footer","open","onClose"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,YAAYA,WAAW,eAAc;AAOrC,SAASC,qBAAqB,EAAEC,cAAc,QAAQ,eAAc;AACpE,SACEC,uBAAuB,EACvBC,qBAAqB,EACrBC,qBAAqB,EACrBC,MAAM,EACNC,kBAAkB,EAClBC,uBAAuB,EACvBC,cAAc,EACdC,kBAAkB,EAClBC,oBAAoB,EACpBC,aAAa,QACR,yBAAwB;AAC/B,SAASC,OAAO,EAAEC,WAAW,EAAEC,OAAO,QAAQ,uBAAsB;AACpE,OAAOC,yBAAyB,wDAAuD;AACvF,SAASC,cAAc,QAAQ,4DAA2D;AAC1F,SAASC,MAAM,EAAEC,IAAI,EAAEC,KAAK,EAAEC,OAAO,EAAEC,UAAU,QAAQ,gBAAe;AACxE,SAASC,SAAS,QAAQ,kBAAiB;AAC3C,SAASC,WAAW,EAAEC,OAAO,EAAEC,QAAQ,QAAQ,QAAO;AACtD,SAASC,mBAAmB,QAAQ,6BAAyB;AAC7D,SAASC,WAAW,QAAQ,qBAAiB;AAC7C,SAGEC,gBAAgB,EAChBC,iBAAiB,EACjBC,kBAAkB,QACb,wBAAoB;AAC3B,SACEC,aAAa,EACbC,uBAAuB,EACvBC,oBAAoB,EACpBC,gBAAgB,QACX,yBAAqB;AAC5B,SAASC,oBAAoB,QAAQ,sBAAkB;AACvD,SAASC,iBAAiB,QAAQ,2BAAuB;AACzD,SAASC,wBAAwB,QAAQ,mCAA+B;AAExE;;;CAGC,GACD,MAAMC,eAAwD;IAC5DC,MAAM5B;IACN6B,QAAQpC;IACRqC,OAAOjC;IACPkC,SAASnC;IACToC,YAAYlC;IACZmC,KAAKvC;IACLwC,QAAQ1C;IACR2C,QAAQpC;IACRqC,SAAS7C;AACX;AAEA;;;;;;;;;;;CAWC,GACD,SAAS8C,YAAYC,KAKpB;QAGejD,+BAYHkD,sBAuBInD;IArCf,MAAM,EAAEmD,WAAW,EAAEC,KAAK,EAAEC,IAAI,EAAE,GAAGH;IACrC,MAAMI,SAASH,YAAYI,IAAI;IAC/B,MAAMC,SAAQvD,gCAAAA,qBAAqB,CAACqD,OAAO,YAA7BrD,gCAAiCwD,OAAOH;IACtD,MAAMI,OAAOnB,YAAY,CAACe,OAAO;IACjC,MAAMK,OAAO,IAAIC,KAAKT,YAAYU,IAAI;IACtC,qBACE,KAAC7B;QACC0B,oBAAM,KAAC3C;YAAQ+C,IAAI,EAAEJ,wBAAAA,KAAMI,IAAI;;QAC/BC,qBACE;;8BACE,KAAC5C;oBAAKqC,OAAOA;oBAAOQ,MAAK;;8BACzB,KAAC7C;oBAAKqC,OAAM;oBAAWQ,MAAK;oBAAQC,SAAQ;;;;QAGhDC,KAAK,GAAEf,uBAAAA,YAAYgB,OAAO,YAAnBhB,uBAAuBK;QAC9BY,oBACE,MAAChD;YACCiD,WAAU;YACVC,SAAS;YACTC,IAAI;gBAAEC,YAAY;gBAAYC,UAAU;gBAAQC,QAAQ;YAAK;;8BAE7D,KAACrD;oBAAQ6C,OAAOP,KAAKgB,cAAc;8BACjC,cAAA,KAACrD;wBAAW2C,SAAQ;wBAAUW,OAAM;kCACjCzB,YAAYW,IAAI,GACb,GAAG9D,MAAM6E,iBAAiB,CAAC1B,YAAYU,IAAI,EAAET,OAAO,GAAG,EAAED,YAAYW,IAAI,EAAE,GAC3E9D,MAAM6E,iBAAiB,CAAC1B,YAAYU,IAAI,EAAET;;;gBASjDC,qBACC,KAAC/B;oBAAW2C,SAAQ;8BAClB,cAAA,KAACpD;wBAAQwC,MAAMA;mCACZrD,wCAAAA,MAAM8E,uBAAuB,CAACxB,OAAO,YAArCtD,wCAAyC;;qBAG5C;;;;AAKd;AACAiD,YAAY8B,WAAW,GAAG;AAE1B;;;;;;;;;;;;;CAaC,GACD,SAASC,iBAAiB9B,KAIzB;QAEc+B,MAAAA;IADb,MAAM,EAAEA,KAAK,EAAE5B,IAAI,EAAED,KAAK,EAAE,GAAGF;IAC/B,MAAMgC,QAAOD,QAAAA,sBAAAA,MAAME,YAAY,YAAlBF,sBAAsBA,MAAMG,OAAO,YAAnCH,OAAuC;IACpD,MAAMd,UAAUnE,MAAMqF,oBAAoB,CAACJ,OAAOK,IAAI,CAAC;IACvD,MAAM3B,OAAO,IAAIC,KAAKqB,MAAMM,QAAQ;IACpC,qBACE,KAACvD;QACC0B,oBAAM,KAAC3C;YAAQ+C,MAAMvD,mBAAmBuD,IAAI;;QAC5CC,qBACE;;8BACE,KAAC5C;oBAAKqC,OAAM;oBAAWQ,MAAK;;8BAC5B,KAAC7C;oBAAKqC,OAAM;oBAAOQ,MAAK;oBAAQC,SAAQ;;;;QAG5CC,qBACE;;gBACGb,qBAAO,KAACxC;oBAAQwC,MAAMA;8BAAO6B;qBAAkBA;gBAC/C,CAAC,GAAG,EAAEf,SAAS;;;QAGpBC,oBACE,KAAC/C;YAAQ6C,OAAOP,KAAKgB,cAAc;sBACjC,cAAA,KAACrD;gBACC2C,SAAQ;gBACRW,OAAM;gBACNL,IAAI;oBAAEiB,WAAW;gBAAa;0BAE7BP,MAAMG,OAAO,IAAIH,MAAMG,OAAO,KAAKF,OAChC,GAAGlF,MAAM6E,iBAAiB,CAACI,MAAMM,QAAQ,EAAEnC,OAAO,GAAG,EAAE6B,MAAMG,OAAO,EAAE,GACtEpF,MAAM6E,iBAAiB,CAACI,MAAMM,QAAQ,EAAEnC;;;;AAMxD;AACA4B,iBAAiBD,WAAW,GAAG;AA+B/B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsCC,GACD,OAAO,SAASU,oBAAoBvC,KAA+B;IACjE,MAAM,EAAEwC,MAAM,EAAEC,GAAG,EAAEC,SAAS,EAAEC,OAAO,EAAEC,YAAY,EAAEC,cAAc,KAAK,EAAE,GAAG7C;IAC/E,MAAM8C,QAAQnE,iBAAiB6D,QAAQC;IACvC;;;;;;GAMC,GACD,MAAMM,eAAexE,QACnB;YACEuE;gBAAAA,sBAAAA,MAAMC,YAAY,YAAlBD,sBACArE,oBAAoBkE,SAAUF,cAAAA,MAAO;OACvC;QAACK,MAAMC,YAAY;QAAEJ;QAASF;KAAI;IAEpC,MAAMO,eAAezE,QAAQ;QAC3B,MAAM0E,QAAQnG,MAAMoG,gBAAgB,CAACP,SAASI,aAAaI,OAAO;QAClE,OAAOrG,MAAMsG,oBAAoB,CAACH,MAAMD,YAAY,EAAED,aAAaM,OAAO;IAC5E,GAAG;QAACV;QAASI;KAAa;IAC1B,MAAMO,OAAO/E,QAA4B,IAAO,CAAA;YAAEmE;QAAU,CAAA,GAAI;QAACA;KAAU;IAC3E,MAAMa,aAAa3E,kBAAkBkE,OAAOQ;IAC5C;;;;;;;;;;GAUC,GACD,MAAME,SAASnF;IACf,MAAMoF,cAAclF,QAClB,IACEiF,CAAAA,0BAAAA,OAAQE,OAAO,MAAIF,0BAAAA,OAAQG,IAAI,IAC3B;YAAED,SAASnD,OAAOiD,OAAOE,OAAO;YAAGC,MAAMpD,OAAOiD,OAAOG,IAAI;QAAE,IAC7D,MACN;QAACH,0BAAAA,OAAQE,OAAO;QAAEF,0BAAAA,OAAQG,IAAI;KAAC;IAEjC,MAAMC,kBAAkBtF,YACtB,CAAC2B,cACCwD,eAAexD,YAAYuC,MAAM,KAAKA,SAClC1F,MAAM+G,sBAAsB,CAAC5D,aAAawD,eAC1C,MACN;QAACA;QAAajB;KAAO;IAEvB,6EAA6E;IAC7E,MAAMsB,YAAY1E,yBAChBoD,iBAAAA,SAAWO,aAAaP,MAAM,IAAI,MAClCE;IAEF,kEAAkE;IAClE,MAAMqB,UAAUlF,mBAAmBiE,MAAMkB,KAAK;IAC9C,MAAMC,UAAU1F,QACd,IACEzB,MAAMoH,oBAAoB,CAAClB,cAAcO,WAAWY,IAAI,EAAEL,UAAUM,MAAM,GAC5E;QAACpB;QAAcO,WAAWY,IAAI;QAAEL,UAAUM,MAAM;KAAC;IAEnD,0EAA0E;IAC1E,sEAAsE;IACtE,mBAAmB;IACnB,MAAMlE,QAAQQ,KAAK2D,GAAG;IAEtB,MAAM,CAACC,YAAYC,cAAc,GAAG/F,SAAS;IAC7C,MAAM,CAACgG,SAASC,WAAW,GAAGjG,SAAgC;IAC9D,MAAMkG,UAAUpG,YAAY;QAC1BmG,WAAW;QACXF,cAAc;IAChB,GAAG,EAAE;IACL,MAAMI,WAAWrG,YAAY,CAACsG;QAC5BH,WAAWG;QACXL,cAAc;IAChB,GAAG,EAAE;IACL,MAAMM,QAAQvG,YAAY,IAAMiG,cAAc,QAAQ,EAAE;IAExD,MAAMO,aAAavG,QACjB,kBAAM,KAACN;YAAKqC,OAAM;YAASQ,MAAK;YAAQC,SAAQ;YAChD,EAAE;IAEJ,MAAMgE,YAAY/F;IAClB,MAAMgG,QAAQ/F,iBAAiBgF,QAAQgB,MAAM,EAAE;QAC7CC,SAAS3B,WAAW2B,OAAO;QAC3BC,UAAU5B,WAAW4B,QAAQ;QAC7BC,SAAS7B,WAAW8B,MAAM,KAAK;IACjC;IACA,MAAMC,YAAYzC,4BAChB,KAAC3D;QAAqB6B,SAAQ;QAAYW,OAAM;kBAC7C;uBAGH,KAAC1D;QACC8C,MAAK;QACLC,SAAQ;QACRW,OAAM;QACN6D,SAASb;QACTc,UAAU,CAACjC,WAAWkC,KAAK;kBAE1B;;IAIL,qBACE;;0BACE,KAAC7H;gBACC8H,QAAQ;gBACRC,MAAM3I,eAAe,qBAAqB;oBAAE4I,QAAQ;gBAAyB;gBAC7EC,aAAa;oBACXC,sBACE,MAAC5H;wBAAMiD,WAAU;wBAAMC,SAAS;;0CAC9B,KAACrC;gCAAwBgG,WAAWA;gCAAWS,UAAU,CAACvB,QAAQgB,MAAM;;4BACvEK;;;gBAGP;gBACAS,cAAc;gBACdC,cAAc;0BAEd,cAAA,MAAC9H;oBAAMkD,SAAS;;wBACbmC,WAAW8B,MAAM,KAAK,wBACrB,KAACjH;4BAAW2C,SAAQ;4BAAQW,OAAM;sCAC/B;6BAED;wBACHoC,UAAUuB,MAAM,KAAK,WAAWvB,UAAUmC,YAAY,GACrD,gEAAgE;wBAChE,2DAA2D;sCAC3D,KAAC7H;4BAAW2C,SAAQ;4BAAQW,OAAM;sCAC/B;6BAED;wBACHuC,QAAQgB,MAAM,KAAK,IAClBnB,UAAUuB,MAAM,KAAK,YACnB,+DAA+D;wBAC/D,sEAAsE;sCACtE,KAACjH;4BAAW2C,SAAQ;4BAAQW,OAAM;sCAC/B;2CAGL,KAAC5D;4BACCoI,OAAO;4BACP5F,OAAO;4BACP6F,aAAa;4BACbL,QAAQR;6BAIVN,MAAMoB,KAAK,CAACnC,SAASoC,GAAG,CAAC,CAACC,QACxBA,MAAMC,IAAI,KAAK,2BACb,KAACxG;gCAECE,aAAaqG,MAAMrG,WAAW;gCAC9BC,OAAOA;gCACPC,MAAMyD,gBAAgB0C,MAAMrG,WAAW;+BAHlCqG,MAAME,GAAG,IAKdF,MAAMC,IAAI,KAAK,2BACjB,KAACzE;gCAECC,OAAOuE,MAAMvE,KAAK;gCAClB5B,MAAMyC,eAAeA,aAAa0D,MAAMvE,KAAK,IAAI;gCACjD7B,OAAOA;+BAHFoG,MAAME,GAAG,kBAMhB,KAAC9H;gCAECkG,UAAU0B,MAAM1B,QAAQ;gCACxB9B,OAAOA;gCACP2D,QAAQ9B;gCACRzC,SAAS4C;gCACT5E,OAAOA;gCACPwG,UAAU,CAAC7D,eAAekB,QAAQuC,MAAM1B,QAAQ;gCAChD+B,UAAU5B,UAAU6B,UAAU,CAACN,MAAME,GAAG;gCACxCK,UAAU,IAAM9B,UAAU+B,MAAM,CAACR,MAAME,GAAG;+BARrCF,MAAME,GAAG;wBAarBvC,QAAQgB,MAAM,iBAAG,KAAClH,6BAAmBiH,MAAM+B,MAAM,KAAO;;;;0BAG7D,KAAC5H;gBACC6H,MAAM1C;gBACN2C,SAASpC;gBACT/B,OAAOA;gBACPQ,MAAMA;gBACNsB,UAAUJ;;;;AAIlB;AACAjC,oBAAoBV,WAAW,GAAG;AAElC,eAAeU,oBAAmB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-timeline-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport type {\n ContactCampaignEmail,\n ContactInteraction,\n ContactSource,\n CrmActivityRow,\n} from '@aglyn/aglyn'\nimport { CONTACT_SOURCE_LABELS, pluginDocsHelp } from '@aglyn/aglyn'\nimport {\n mdiAccountCircleOutline,\n mdiAccountEditOutline,\n mdiAccountPlusOutline,\n mdiApi,\n mdiBullhornOutline,\n mdiCalendarCheckOutline,\n mdiCartOutline,\n mdiEmailNewsletter,\n mdiFileImportOutline,\n mdiFormSelect,\n} from '@aglyn/shared-data-mdi'\nimport { AppLink, CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport EmptyStateComponent from '@aglyn/shared-ui-jsx/components/empty-state.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { Button, Chip, Stack, Tooltip, Typography } from '@mui/material'\nimport { useParams } from 'next/navigation'\nimport { useCallback, useMemo, useState } from 'react'\nimport { contactInteractionHref, INTERACTION_LINK_LABELS } from '../model/contact-interaction-links'\nimport { contactPrimaryGroup } from '../model/contact-record'\nimport { ActivityRow } from './activity-list'\nimport {\n type ActivityRecordLink,\n type CrmOrg,\n useActivityScope,\n useActivityWindow,\n useCanEditActivity,\n} from './activity-queries'\nimport {\n TimelineEntry,\n TimelineExpandAllButton,\n useTimelineExpansion,\n useTimelinePages,\n} from './activity-timeline'\nimport { CrmSuiteLockedButton } from './crm-suite-lock'\nimport { LogActivityDialog } from './log-activity-dialog'\nimport { useContactCampaignEmails } from './use-contact-campaign-emails'\n\n/**\n * One glyph per capture door. Typed against the source union so a door added\n * to it cannot render as the placeholder glyph.\n */\nconst SOURCE_ICONS: Record<ContactSource, { path: string }> = {\n form: mdiFormSelect,\n member: mdiAccountPlusOutline,\n order: mdiCartOutline,\n booking: mdiCalendarCheckOutline,\n newsletter: mdiEmailNewsletter,\n api: mdiApi,\n manual: mdiAccountEditOutline,\n import: mdiFileImportOutline,\n account: mdiAccountCircleOutline,\n}\n\n/**\n * One thing the platform recorded: the door it came through, what the door\n * wrote, when, and the page the person was on.\n *\n * Read-only by nature — nobody logged it, so nobody edits it. The entry\n * point sits beside the timestamp rather than in the summary, the way the\n * contacts drawer draws it: the summary is the sentence the door wrote, the\n * path is a fact the door recorded, and a long path on the sentence's line\n * pushes out the thing the row is about.\n *\n * Everything it holds fits the collapsed row, so it has nothing to open.\n */\nfunction CapturedRow(props: {\n interaction: ContactInteraction\n nowMs: number\n /** The console page the door's record is read on, when it left one. */\n href: string | null\n}) {\n const { interaction, nowMs, href } = props\n const source = interaction.type\n const label = CONTACT_SOURCE_LABELS[source] ?? String(source)\n const icon = SOURCE_ICONS[source]\n const when = new Date(interaction.atMs)\n return (\n <TimelineEntry\n icon={<MdiIcon path={icon?.path} />}\n chips={\n <>\n <Chip label={label} size=\"small\" />\n <Chip label=\"Captured\" size=\"small\" variant=\"outlined\" />\n </>\n }\n title={interaction.summary ?? label}\n meta={\n <Stack\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'baseline', flexWrap: 'wrap', rowGap: 0.25 }}\n >\n <Tooltip title={when.toLocaleString()}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {interaction.path\n ? `${Aglyn.activityTimeLabel(interaction.atMs, nowMs)} · ${interaction.path}`\n : Aglyn.activityTimeLabel(interaction.atMs, nowMs)}\n </Typography>\n </Tooltip>\n {/*\n The record the door left, where the console reads it (AGL-2622):\n the submission in the Inbox reader, the order in its dialog. A door\n that left nothing to open — a newsletter opt-in, an import — has no\n link, and the caption is the whole entry.\n */}\n {href ? (\n <Typography variant=\"caption\">\n <AppLink href={href}>\n {INTERACTION_LINK_LABELS[source] ?? 'Open'}\n </AppLink>\n </Typography>\n ) : null}\n </Stack>\n }\n />\n )\n}\nCapturedRow.displayName = 'CapturedRow'\n\n/**\n * One campaign email the platform sent this person (AGL-2616): which email,\n * and what became of it — `sent · delivered · opened ×2 · clicked`.\n *\n * Read-only like a captured row, and drawn apart from BOTH the captured and\n * the logged rows: its glyph is the campaign's, never the envelope a logged\n * `email` activity carries, and its chips say \"Campaign\" and \"Sent\" where\n * the others say \"Captured\" and \"Logged\". The name links to the email's own\n * report when the page can address it; an email the team has deleted, or\n * one a sibling site sent, keeps its name and loses the link.\n *\n * The delivery line stays on the collapsed row beside the name, so it has\n * nothing to open.\n */\nfunction CampaignEmailRow(props: {\n email: ContactCampaignEmail\n href: string | null\n nowMs: number\n}) {\n const { email, href, nowMs } = props\n const name = email.campaignName ?? email.subject ?? 'Campaign email'\n const summary = Aglyn.campaignEmailSummary(email).join(' · ')\n const when = new Date(email.sentAtMs)\n return (\n <TimelineEntry\n icon={<MdiIcon path={mdiBullhornOutline.path} />}\n chips={\n <>\n <Chip label=\"Campaign\" size=\"small\" />\n <Chip label=\"Sent\" size=\"small\" variant=\"outlined\" />\n </>\n }\n title={\n <>\n {href ? <AppLink href={href}>{name}</AppLink> : name}\n {` · ${summary}`}\n </>\n }\n meta={\n <Tooltip title={when.toLocaleString()}>\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ alignSelf: 'flex-start' }}\n >\n {email.subject && email.subject !== name\n ? `${Aglyn.activityTimeLabel(email.sentAtMs, nowMs)} · ${email.subject}`\n : Aglyn.activityTimeLabel(email.sentAtMs, nowMs)}\n </Typography>\n </Tooltip>\n }\n />\n )\n}\nCampaignEmailRow.displayName = 'CampaignEmailRow'\n\nexport interface ContactTimelineCardProps {\n /** The site the record is read under, or `null` at the organization level. */\n hostId: string | null\n org: CrmOrg\n contactId: string\n /**\n * The RAW contact document, as the page's listener hands it back. The card\n * resolves the viewing group and reads that group's facet itself, so a\n * page cannot hand it another holder's timeline by flattening the row\n * through the wrong group.\n */\n contact: Record<string, unknown> | null | undefined\n /**\n * Where a campaign entry links to — the email's report — or `null` for\n * one the page cannot address. Handed in rather than built here because\n * the report lives on another surface whose path is the MOUNT's to know:\n * a site's record page reaches its own Emails hub, an org-level mount\n * holds the org's site list and can reach every site's. No builder means\n * no links, never a guessed one.\n */\n campaignHref?: (email: ContactCampaignEmail) => string | null\n /**\n * The org's plan lacks the CRM (AGL-2788), which the shell mounts no CRM\n * page for (AGL-2851). The history still draws; logging an activity stands\n * locked, and one already logged cannot be edited.\n */\n suiteLocked?: boolean\n}\n\n/**\n * A person's whole history in one newest-first stream (AGL-2600): what the\n * platform CAPTURED — a form, an order, a booking, a sign-up — what a person\n * LOGGED beside it — a call, an email, a meeting, a note — and, since\n * AGL-2616, the campaign mail the platform SENT them and what became of it.\n *\n * ## Why one stream\n *\n * The histories answer one question, \"what has happened with this person\",\n * and a page showing them as separate lists asks the reader to interleave\n * them by eye. The merge is `mergeContactTimeline`, pure and specced: newest\n * first, stable at a tie, an undated entry last, a campaign at the instant\n * it was sent.\n *\n * Each entry says which it is. A captured entry carries the door's label and\n * a \"Captured\" chip and offers no controls; a logged entry carries its kind\n * and a \"Logged\" chip, and its author may edit or delete it; a campaign\n * entry carries \"Campaign\" and \"Sent\", links to the email's report, and is\n * nobody's to edit.\n *\n * ## What is read\n *\n * The captured side comes off the contact document the page already holds\n * — THIS group's facet, filtered to the sites the group may see, the same\n * projection the contacts list makes — and costs no read. The logged side is\n * the bounded listener every activity surface uses, filtered to this\n * contact, a hundred at a time. The campaign side is one request to\n * `crm/contact-email-history`, which reads down this person's own delivery\n * log, capped, and never a campaign's recipients. The facet's history is\n * capped at fifty by the platform, so past the window the stream is the\n * logged side alone.\n *\n * ## How it reads\n *\n * Every entry is a collapsed row that opens in place, paged by the\n * console's footer over the merged stream; a page turned past the logged\n * window widens it by the next hundred. **Expand all** and **Log activity**\n * sit in the card header, and edit and delete stay on the entry they act on.\n */\nexport function ContactTimelineCard(props: ContactTimelineCardProps) {\n const { hostId, org, contactId, contact, campaignHref, suiteLocked = false } = props\n const scope = useActivityScope(hostId, org)\n /*\n * THIS holder's captured history, and only the visits this group may see.\n * A read off the top of the document, or of another group's facet, would\n * be another business's records — the disclosure the facet shape ended.\n * At the organization level (AGL-2630) the holder is the person's own\n * primary one, the group the record page reads the profile through.\n */\n const consentGroup = useMemo(\n () =>\n scope.consentGroup ??\n contactPrimaryGroup(contact, (org ?? null) as Record<string, unknown> | null),\n [scope.consentGroup, contact, org],\n )\n const interactions = useMemo(() => {\n const facet = Aglyn.readContactFacet(contact, consentGroup.groupId)\n return Aglyn.interactionsForGroup(facet.interactions, consentGroup.hostIds)\n }, [contact, consentGroup])\n const link = useMemo<ActivityRecordLink>(() => ({ contactId }), [contactId])\n const activities = useActivityWindow(scope, link)\n /*\n * Where a captured entry's record is READ (AGL-2622). The site console's\n * two route params are already in the URL this page is on, so the link is\n * built from them rather than from two document reads per timeline. Only\n * an interaction on THIS site links: a sibling site's order is that\n * site's record, read on that site's console, and a link built with this\n * site's segment would 404 or — worse — open the wrong site's row under\n * the same id. An interaction with no site recorded predates the stamp\n * and is left unlinked rather than guessed at. Off a site — the org-level\n * mount — there is no segment to build with, and nothing links.\n */\n const params = useParams<{ orgSlug?: string; host?: string }>()\n const siteContext = useMemo(\n () =>\n params?.orgSlug && params?.host\n ? { orgSlug: String(params.orgSlug), host: String(params.host) }\n : null,\n [params?.orgSlug, params?.host],\n )\n const interactionHref = useCallback(\n (interaction: ContactInteraction): string | null =>\n siteContext && interaction.hostId === hostId\n ? contactInteractionHref(interaction, siteContext)\n : null,\n [siteContext, hostId],\n )\n // The campaign history is asked of a site: the mounted one, or the holder's.\n const campaigns = useContactCampaignEmails(\n hostId ?? (consentGroup.hostId || null),\n contactId,\n )\n // One member read for the whole stream, however many rows it has.\n const canEdit = useCanEditActivity(scope.orgId)\n const entries = useMemo(\n () =>\n Aglyn.mergeContactTimeline(interactions, activities.rows, campaigns.emails),\n [interactions, activities.rows, campaigns.emails],\n )\n // One clock for every row of one paint, so two rows logged a second apart\n // cannot read \"just now\" and \"1 min ago\" from being rendered across a\n // minute boundary.\n const nowMs = Date.now()\n\n const [dialogOpen, setDialogOpen] = useState(false)\n const [editing, setEditing] = useState<CrmActivityRow | null>(null)\n const openNew = useCallback(() => {\n setEditing(null)\n setDialogOpen(true)\n }, [])\n const openEdit = useCallback((activity: CrmActivityRow) => {\n setEditing(activity)\n setDialogOpen(true)\n }, [])\n const close = useCallback(() => setDialogOpen(false), [])\n\n const loggedChip = useMemo(\n () => <Chip label=\"Logged\" size=\"small\" variant=\"outlined\" />,\n [],\n )\n const expansion = useTimelineExpansion()\n const pages = useTimelinePages(entries.length, {\n hasMore: activities.hasMore,\n showMore: activities.showMore,\n loading: activities.status === 'loading',\n })\n const logButton = suiteLocked ? (\n <CrmSuiteLockedButton variant=\"contained\" color=\"primary\">\n {'Log activity'}\n </CrmSuiteLockedButton>\n ) : (\n <Button\n size=\"small\"\n variant=\"contained\"\n color=\"primary\"\n onClick={openNew}\n disabled={!activities.ready}\n >\n {'Log activity'}\n </Button>\n )\n\n return (\n <>\n <CardDisplay\n header={'Timeline'}\n help={pluginDocsHelp('contactActivities', { anchor: '#four-kinds-of-history' })}\n HeaderProps={{\n action: (\n <Stack direction=\"row\" spacing={1}>\n <TimelineExpandAllButton expansion={expansion} disabled={!entries.length} />\n {logButton}\n </Stack>\n ),\n }}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {activities.status === 'error' ? (\n <Typography variant=\"body2\" color=\"error\">\n {'Logged activity could not be loaded; what follows is what the platform captured.'}\n </Typography>\n ) : null}\n {campaigns.status === 'error' || campaigns.lookupFailed ? (\n // \"Unknown\", not \"none\": an empty campaign history and one that\n // could not be read lead a reader to opposite conclusions.\n <Typography variant=\"body2\" color=\"error\">\n {'Campaign email could not be loaded; the stream below may be missing the mail this person was sent.'}\n </Typography>\n ) : null}\n {entries.length === 0 ? (\n campaigns.status === 'loading' ? (\n // The campaign history is one request on mount; an empty state\n // before it answers would read as \"none\" for a person who was mailed.\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Loading…'}\n </Typography>\n ) : (\n <EmptyStateComponent\n compact\n label={'No history yet'}\n description={'What this person does on the site, the campaigns they are sent, and what you log about them, shows here.'}\n action={logButton}\n />\n )\n ) : (\n pages.slice(entries).map((entry) =>\n entry.kind === 'captured' ? (\n <CapturedRow\n key={entry.key}\n interaction={entry.interaction}\n nowMs={nowMs}\n href={interactionHref(entry.interaction)}\n />\n ) : entry.kind === 'campaign' ? (\n <CampaignEmailRow\n key={entry.key}\n email={entry.email}\n href={campaignHref ? campaignHref(entry.email) : null}\n nowMs={nowMs}\n />\n ) : (\n <ActivityRow\n key={entry.key}\n activity={entry.activity}\n scope={scope}\n onEdit={openEdit}\n subject={loggedChip}\n nowMs={nowMs}\n editable={!suiteLocked && canEdit(entry.activity)}\n expanded={expansion.isExpanded(entry.key)}\n onToggle={() => expansion.toggle(entry.key)}\n />\n ),\n )\n )}\n {entries.length ? <ListPagination {...pages.footer} /> : null}\n </Stack>\n </CardDisplay>\n <LogActivityDialog\n open={dialogOpen}\n onClose={close}\n scope={scope}\n link={link}\n activity={editing}\n />\n </>\n )\n}\nContactTimelineCard.displayName = 'ContactTimelineCard'\n\nexport default ContactTimelineCard\n"],"names":["Aglyn","CONTACT_SOURCE_LABELS","pluginDocsHelp","mdiAccountCircleOutline","mdiAccountEditOutline","mdiAccountPlusOutline","mdiApi","mdiBullhornOutline","mdiCalendarCheckOutline","mdiCartOutline","mdiEmailNewsletter","mdiFileImportOutline","mdiFormSelect","AppLink","CardDisplay","MdiIcon","EmptyStateComponent","ListPagination","Button","Chip","Stack","Tooltip","Typography","useParams","useCallback","useMemo","useState","contactInteractionHref","INTERACTION_LINK_LABELS","contactPrimaryGroup","ActivityRow","useActivityScope","useActivityWindow","useCanEditActivity","TimelineEntry","TimelineExpandAllButton","useTimelineExpansion","useTimelinePages","CrmSuiteLockedButton","LogActivityDialog","useContactCampaignEmails","SOURCE_ICONS","form","member","order","booking","newsletter","api","manual","import","account","CapturedRow","props","interaction","nowMs","href","source","type","label","String","icon","when","Date","atMs","path","chips","size","variant","title","summary","meta","direction","spacing","sx","alignItems","flexWrap","rowGap","toLocaleString","color","activityTimeLabel","displayName","CampaignEmailRow","email","name","campaignName","subject","campaignEmailSummary","join","sentAtMs","alignSelf","ContactTimelineCard","hostId","org","contactId","contact","campaignHref","suiteLocked","scope","consentGroup","interactions","facet","readContactFacet","groupId","interactionsForGroup","hostIds","link","activities","params","siteContext","orgSlug","host","interactionHref","campaigns","canEdit","orgId","entries","mergeContactTimeline","rows","emails","now","dialogOpen","setDialogOpen","editing","setEditing","openNew","openEdit","activity","close","loggedChip","expansion","pages","length","hasMore","showMore","loading","status","logButton","onClick","disabled","ready","header","help","anchor","HeaderProps","action","contentGutterX","contentGutterY","lookupFailed","compact","description","slice","map","entry","kind","key","onEdit","editable","expanded","isExpanded","onToggle","toggle","footer","open","onClose"],"mappings":"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"}
@@ -24,9 +24,9 @@
24
24
  * the rows it reaches (`contacts-bulk-writes.ts` decides which), sent in
25
25
  * pieces the route accepts. A stage move is `crm/contact-stage`, row by row:
26
26
  * a move is what an automation listens for, and only the route that
27
- * performed it can announce it. Letting the rows go is the one act written
28
- * client-direct, because a holder dropping its own facet is the one facet
29
- * change the rules leave the browser.
27
+ * performed it can announce it. Letting the rows go is `crm/contact-remove`
28
+ * (AGL-3338): the route decides detach or delete against each stored
29
+ * document and keeps any marketing refusal the person gave.
30
30
  *
31
31
  * ## A refused row is named
32
32
  *
@@ -42,9 +42,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
42
42
  * the rows it reaches (`contacts-bulk-writes.ts` decides which), sent in
43
43
  * pieces the route accepts. A stage move is `crm/contact-stage`, row by row:
44
44
  * a move is what an automation listens for, and only the route that
45
- * performed it can announce it. Letting the rows go is the one act written
46
- * client-direct, because a holder dropping its own facet is the one facet
47
- * change the rules leave the browser.
45
+ * performed it can announce it. Letting the rows go is `crm/contact-remove`
46
+ * (AGL-3338): the route decides detach or delete against each stored
47
+ * document and keeps any marketing refusal the person gave.
48
48
  *
49
49
  * ## A refused row is named
50
50
  *
@@ -61,23 +61,23 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
61
61
  */ import { CONTACT_LIFECYCLE_STAGE_LABELS, CONTACT_LIFECYCLE_STAGES, crmMemberPickerLabel } from "@aglyn/aglyn";
62
62
  import { useConfirmationContext } from "@aglyn/shared-ui-jsx";
63
63
  import { Button, MenuItem, TextField } from "@mui/material";
64
- import { deleteDoc, doc, updateDoc, writeBatch } from "firebase/firestore";
65
64
  import { useCallback, useMemo, useState } from "react";
66
- import { useFirestore, useOrgMemberOptions, useUser } from "@aglyn/tenant-feature-instance";
65
+ import { useOrgMemberOptions, useUser } from "@aglyn/tenant-feature-instance";
66
+ import { useContactRemove } from "../hooks/use-contact-remove.js";
67
67
  import { useContactUpdate } from "../hooks/use-contact-update.js";
68
68
  import { useCrmBulkApply } from "../hooks/use-crm-bulk-apply.js";
69
69
  import { useCrmScope } from "../hooks/use-crm-scope.js";
70
+ import { CRM_CONTACT_REMOVE_MAX } from "../model/contact-remove.js";
70
71
  import { CRM_CONTACT_UPDATE_MAX } from "../model/contact-update.js";
71
72
  import { setContactStage } from "../model/crm-api.js";
72
- import { contactBulkAddressOf, normalizeBulkTag, planAddTag, planDetach, planRemoveTag, planSetCompany } from "../model/contacts-bulk-writes.js";
73
+ import { contactBulkAddressOf, normalizeBulkTag, planAddTag, planRemoveTag, planSetCompany } from "../model/contacts-bulk-writes.js";
73
74
  import { contactsCsv, downloadTextFile } from "../model/contacts-csv.js";
74
- import { runCrmBulkBatch, runCrmBulkCalls, runCrmBulkWrites } from "../model/crm-bulk-writes.js";
75
+ import { runCrmBulkBatch, runCrmBulkCalls } from "../model/crm-bulk-writes.js";
75
76
  import AddToListDialog from "./add-to-list-dialog.js";
76
77
  import { CompanyPicker, useCompanyOptions, useCreateCompany } from "./company-picker.js";
77
78
  import { CrmBulkBarFrame, CrmBulkValueDialog, countNoun } from "./crm-bulk-bar-frame.js";
78
79
  import CrmExportAllButton from "./crm-export-all-button.js";
79
- import { restampCrmListFields } from "../model/list-fields-api.js";
80
- import { crmTaskCallScope } from "../model/task-routes.js";
80
+ import { CrmBulkShareButton } from "./record-sharing-card.js";
81
81
  import { CrmSuiteLockedButton } from "./crm-suite-lock.js";
82
82
  const NOUN = {
83
83
  singular: 'contact',
@@ -124,15 +124,15 @@ const ACTION_TITLES = {
124
124
  }
125
125
  ContactsBulkBar.displayName = 'ContactsBulkBar';
126
126
  function ContactsBulkBarBody(props) {
127
- var _team_error, _ref, _ref1;
127
+ var _team_error, _ref, _ref1, _ref2;
128
128
  const { hostId, org, scope, consentGroup, rows, selected, onSelectedChange, csv, suiteLocked = false } = props;
129
- const firestore = useFirestore();
130
129
  const { data: user } = useUser();
131
130
  const { confirm } = useConfirmationContext();
132
131
  const { busy, report, apply, dismissReport } = useCrmBulkApply({
133
132
  recordKind: 'contact'
134
133
  });
135
134
  const contactUpdate = useContactUpdate(hostId);
135
+ const contactRemove = useContactRemove(hostId);
136
136
  const selectedRows = useMemo(()=>{
137
137
  const chosen = new Set(selected);
138
138
  return rows.filter((row)=>chosen.has(row.$id));
@@ -172,32 +172,6 @@ function ContactsBulkBarBody(props) {
172
172
  setCompany(null);
173
173
  setPending(action);
174
174
  };
175
- /**
176
- * The Firestore writers for the one act written client-direct: letting the
177
- * rows go, a batch at a time and a row at a time for a batch that failed.
178
- */ const writers = useMemo(()=>{
179
- const refFor = (id)=>{
180
- var _ref, _ref1;
181
- return doc(firestore, (_ref = scope == null ? void 0 : scope[0]) != null ? _ref : 'orgs', (_ref1 = scope == null ? void 0 : scope[1]) != null ? _ref1 : '', 'contacts', id);
182
- };
183
- return {
184
- commitBatch: async (writes)=>{
185
- const batch = writeBatch(firestore);
186
- for (const write of writes){
187
- if (write.kind === 'delete') batch.delete(refFor(write.id));
188
- else batch.update(refFor(write.id), write.data);
189
- }
190
- await batch.commit();
191
- },
192
- commitOne: async (write)=>{
193
- if (write.kind === 'delete') await deleteDoc(refFor(write.id));
194
- else await updateDoc(refFor(write.id), write.data);
195
- }
196
- };
197
- }, [
198
- firestore,
199
- scope
200
- ]);
201
175
  /** One act's request for the rows it reaches, in the pieces the route accepts. */ const saveThroughRoute = useCallback((targets, set)=>runCrmBulkBatch(targets, (row)=>row.$id, contactBulkAddressOf, (piece)=>contactUpdate.updateMany(piece.map((row)=>row.$id), set), CRM_CONTACT_UPDATE_MAX), [
202
176
  contactUpdate
203
177
  ]);
@@ -305,7 +279,6 @@ function ContactsBulkBarBody(props) {
305
279
  csv
306
280
  ]);
307
281
  const handleDetach = useCallback(async ()=>{
308
- var _ref;
309
282
  if (!scope || !selectedRows.length || !consentGroup) return;
310
283
  const count = selectedRows.length;
311
284
  const confirmed = await confirm({
@@ -318,31 +291,24 @@ function ContactsBulkBarBody(props) {
318
291
  })// `confirm` resolves with no value and REJECTS on cancel.
319
292
  .then(()=>true).catch(()=>false);
320
293
  if (!confirmed) return;
321
- const plan = planDetach(selectedRows, consentGroup, Date.now());
322
294
  const outcome = await apply({
323
- attempted: plan.writes.length,
324
- skipped: plan.skipped.map(skippedLine),
325
- job: ()=>runCrmBulkWrites(writers, plan.writes, (write)=>write.email),
295
+ attempted: selectedRows.length,
296
+ skipped: [],
297
+ job: ()=>runCrmBulkBatch(selectedRows, (row)=>row.$id, contactBulkAddressOf, (piece)=>contactRemove.removeMany(piece.map((row)=>row.$id)), CRM_CONTACT_REMOVE_MAX),
326
298
  done: (done)=>doneSentence('detach', done)
327
299
  });
328
300
  // The rows that went are gone from the table; the refused ones stay
329
301
  // selected, so the reader can see which they are and try again.
330
302
  const refused = new Set(outcome.refused.map((row)=>row.label));
331
- // A detached row still carries the facet keys and scoped search tokens
332
- // of the holder that let it go; the rules keep a client from restamping
333
- // them, so the route does (AGL-3321). A deleted row answers `missing`.
334
- await restampCrmListFields(user, crmTaskCallScope(hostId, (_ref = scope == null ? void 0 : scope[1]) != null ? _ref : null), 'contacts', selectedRows.filter((row)=>!refused.has(contactBulkAddressOf(row))).map((row)=>row.$id));
335
303
  onSelectedChange(selectedRows.filter((row)=>refused.has(contactBulkAddressOf(row))).map((row)=>row.$id));
336
304
  }, [
337
305
  scope,
338
306
  selectedRows,
339
307
  confirm,
340
308
  apply,
341
- writers,
309
+ contactRemove,
342
310
  consentGroup,
343
- onSelectedChange,
344
- user,
345
- hostId
311
+ onSelectedChange
346
312
  ]);
347
313
  const emails = selectedRows.map((row)=>{
348
314
  var _row_email;
@@ -474,6 +440,13 @@ function ContactsBulkBarBody(props) {
474
440
  onClick: ()=>setListOpen(true),
475
441
  children: 'Add to list'
476
442
  }),
443
+ /*#__PURE__*/ _jsx(CrmBulkShareButton, {
444
+ object: "contacts",
445
+ ids: selectedRows.map((row)=>row.$id),
446
+ hostId: hostId != null ? hostId : null,
447
+ orgId: (_ref1 = scope == null ? void 0 : scope[1]) != null ? _ref1 : null,
448
+ disabled: busy
449
+ }),
477
450
  /*#__PURE__*/ _jsx(Button, {
478
451
  size: "small",
479
452
  disabled: busy,
@@ -482,7 +455,7 @@ function ContactsBulkBarBody(props) {
482
455
  }),
483
456
  /*#__PURE__*/ _jsx(CrmExportAllButton, {
484
457
  resource: "contacts",
485
- orgId: (_ref1 = scope == null ? void 0 : scope[1]) != null ? _ref1 : null,
458
+ orgId: (_ref2 = scope == null ? void 0 : scope[1]) != null ? _ref2 : null,
486
459
  hostId: hostId,
487
460
  disabled: busy
488
461
  }),