@aglyn/plugins-crm 1.0.0-beta.218 → 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/company-picker.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 AglynOrgBilling,\n createResourceUid,\n CRM_COLLECTIONS,\n CRM_RECORDS_BAND_FULL_MESSAGE,\n crmNewRecordListFields,\n} from '@aglyn/aglyn'\nimport {\n useFirestore,\n useFirestoreCollection,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Autocomplete,\n Button,\n createFilterOptions,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n doc,\n limit,\n orderBy,\n query,\n serverTimestamp,\n setDoc,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { readCrmRecordsQuota } from '../hooks/use-crm-records-quota'\nimport { crmVisibleToClause, useCrmScope } from '../hooks/use-crm-scope'\nimport {\n type CompanyOption,\n companyDraftFields,\n EMPTY_COMPANY_DRAFT,\n suggestCompanyForEmail,\n} from '../model/companies'\n\nexport { suggestCompanyForEmail, type CompanyOption }\n\n/**\n * How many companies the picker lists.\n *\n * A ceiling and not a page: somebody choosing a company knows which one, and\n * a workspace past two hundred accounts finds it from the company list's own\n * search and links the person from there. Bounded because this read happens\n * on a contact's page, where the company list is a control and not the\n * subject.\n */\nexport const COMPANY_OPTIONS_LIMIT = 200\n\nexport interface CompanyOptions {\n options: CompanyOption[]\n /** The listen has answered at least once. */\n ready: boolean\n /** The ceiling cut the list, so a company can exist and not be offered. */\n truncated: boolean\n}\n\n/**\n * The companies this viewer may link a contact to, alphabetically\n * (AGL-2597).\n *\n * Read under the same `visibleTo` predicate the company list runs, because\n * the rules refuse a listen without it for a scoped member — and because a\n * picker offering a company the reader could not open would let them file a\n * person under a record they cannot see. Ordered by `nameLower`, the key\n * every company write stores, so the picker reads as a list of names rather\n * than as document-id order dressed up.\n *\n * `enabled` gates the listen: a contact's properties card has one picker and\n * a merchant who opened the record to read a note must not pay for the\n * company list.\n */\nexport function useCompanyOptions(props: {\n /** The site whose console is reading, or `null` at the organization level. */\n hostId: string | null\n org?: Partial<AglynOrgBilling> | null\n enabled?: boolean\n}): CompanyOptions {\n const { hostId, org, enabled = true } = props\n const firestore = useFirestore()\n const { scope, visibleTo } = useCrmScope({ hostId, org })\n const { data, status } = useFirestoreCollection<Record<string, unknown>>(\n () =>\n enabled && scope\n ? query(\n collection(firestore, scope[0], scope[1], CRM_COLLECTIONS.companies),\n ...crmVisibleToClause(visibleTo),\n orderBy('nameLower'),\n limit(COMPANY_OPTIONS_LIMIT + 1),\n )\n : null,\n [firestore, scope, visibleTo, enabled],\n { idField: '$id' },\n )\n const options = useMemo<CompanyOption[]>(\n () =>\n (data ?? []).slice(0, COMPANY_OPTIONS_LIMIT).map((row) => ({\n id: String(row['$id']),\n name: String(row['name'] ?? ''),\n domain: typeof row['domain'] === 'string' ? row['domain'] : null,\n })),\n [data],\n )\n return {\n options,\n ready: Boolean(scope) && status !== 'loading',\n truncated: (data?.length ?? 0) > COMPANY_OPTIONS_LIMIT,\n }\n}\n\n/**\n * Create a company by name, answering the option the picker then selects.\n * Rejects with the sentence to show when the name cannot be stored.\n */\nexport type CreateCompany = (name: string) => Promise<CompanyOption>\n\n/**\n * The write behind \"Create “Acme”\" in the picker (AGL-2613).\n *\n * The same document the company drawer writes for a create — the search\n * keys, the scope every CRM creator stamps, the provenance — with only the\n * name filled in, because that is all the picker asked for: the domain, the\n * address and the rest are the company page's to add. The person filing it\n * owns it, as the drawer seeds too. `null` until the org has resolved, so a\n * picker offered before the scope is known offers no create.\n *\n * A company is a record of the band the drawers refuse on (AGL-2644): an\n * org at a full hard band is refused here with the drawers' sentence, and\n * the picker shows it beneath the field the way it shows any create that\n * failed. Measured on the click and not on the mount — see\n * `readCrmRecordsQuota` — and never for an edit, because linking an\n * existing company adds no record.\n */\nexport function useCreateCompany(props: {\n /** The site whose console is creating, or `null` at the organization level. */\n hostId: string | null\n org?: Partial<AglynOrgBilling> | null\n}): CreateCompany | null {\n const { hostId, org } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n // The provenance and the scope both come from the site the record is\n // captured by — the mounted one, or at the organization level the one the\n // reader picked (AGL-2630). No site picked, no create offered.\n const { scope, createTokens, createHostId } = useCrmScope({ hostId, org })\n const uid = user?.uid ?? ''\n return useMemo<CreateCompany | null>(() => {\n if (!scope || !createHostId) return null\n return async (name) => {\n const result = companyDraftFields({\n ...EMPTY_COMPANY_DRAFT,\n name,\n ownerUid: uid,\n })\n if (result.ok === false) throw new Error(result.error)\n const quota = await readCrmRecordsQuota(firestore, scope, org)\n if (!quota.allowed) throw new Error(CRM_RECORDS_BAND_FULL_MESSAGE)\n const id = createResourceUid()\n await setDoc(\n doc(firestore, scope[0], scope[1], CRM_COLLECTIONS.companies, id),\n {\n ...result.set,\n // What the Companies list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('companies', { ...result.set, visibleTo: [...createTokens] }),\n visibleTo: [...createTokens],\n hostId: createHostId,\n createdByUid: uid,\n createdAt: serverTimestamp(),\n updatedAt: serverTimestamp(),\n },\n )\n return { id, name: String(result.set['name']), domain: null }\n }\n }, [scope, createTokens, firestore, createHostId, uid, org])\n}\n\n/** The sentinel id of the \"Create …\" row the list grows when nothing matches. */\nconst CREATE_OPTION_ID = '__create__'\n\ntype PickerOption = CompanyOption | { id: typeof CREATE_OPTION_ID; name: string; create: string }\n\nconst isCreateOption = (\n option: PickerOption,\n): option is Extract<PickerOption, { create: string }> => 'create' in option\n\nconst filterCompanies = createFilterOptions<PickerOption>({\n stringify: (option) =>\n isCreateOption(option)\n ? option.create\n : option.domain\n ? `${option.name} ${option.domain}`\n : option.name,\n})\n\nconst optionLabel = (option: PickerOption): string =>\n isCreateOption(option)\n ? `Create “${option.create}”`\n : option.domain\n ? `${option.name} · ${option.domain}`\n : option.name\n\nexport interface CompanyPickerProps {\n options: readonly CompanyOption[]\n /** The linked company's id, or `null` for none. */\n value: string | null\n /**\n * The choice, with the option behind it when the list — or a create — can\n * name it, so a caller can echo the company's name onto the contact\n * without a second lookup. `null` for a company the list does not carry.\n */\n onChange: (companyId: string | null, company: CompanyOption | null) => void\n /**\n * Create a company from the typed name and select it. Absent — or `null`\n * while the scope is unresolved — the picker offers no create row.\n */\n onCreate?: CreateCompany | null\n label?: string\n helperText?: string\n disabled?: boolean\n /**\n * The contact's email address, when the caller has one. A company whose\n * domain matches it is offered as a suggestion beneath the field — a\n * suggestion and never an automatic link, because the person who typed\n * `@acme.com` may be a contractor, a customer of Acme's, or Acme itself.\n */\n email?: string | null\n /**\n * The company name the record carries as free text while no company is\n * linked — an import, or a save from before the picker existed. Offered\n * beneath the field as the company to link or create, so the label a\n * merchant already typed is one click from becoming the record it names.\n */\n fallbackName?: string | null\n /** The option list has answered; until then the field says it is loading. */\n ready?: boolean\n /** The list was cut at the ceiling, so the field says to narrow by typing. */\n truncated?: boolean\n}\n\n/**\n * One company, chosen from the list a contact may be filed under\n * (AGL-2597, AGL-2613).\n *\n * A search over the loaded list rather than free text, because the value is\n * a document id and a typed name is how two records called \"Acme\" come to\n * exist. Typing narrows by name and domain; a name nothing matches grows the\n * list by one row, \"Create “…”\", which writes the company and selects it —\n * so the person filing a contact never has to leave the form to make the\n * account it belongs to. The clear control is the unlink: a saved contact\n * with no company reads as a decision rather than a field somebody skipped.\n *\n * A stored id the list does not carry — a company past the picker's ceiling,\n * or one this viewer cannot see — still renders as SOMETHING selected, or\n * the field would read empty for a contact that has a company and the next\n * save would silently unlink them.\n */\nexport function CompanyPicker(props: CompanyPickerProps) {\n const {\n options,\n value,\n onChange,\n onCreate,\n label = 'Company',\n helperText,\n disabled,\n email,\n fallbackName,\n ready = true,\n truncated,\n } = props\n const [creating, setCreating] = useState(false)\n const [createError, setCreateError] = useState('')\n\n const suggestion = useMemo(\n () => (email ? suggestCompanyForEmail(email, options) : null),\n [email, options],\n )\n const fallback = useMemo(() => {\n const name = String(fallbackName ?? '').trim()\n if (value || !name) return null\n const lower = name.toLowerCase()\n return {\n name,\n match: options.find((option) => option.name.trim().toLowerCase() === lower) ?? null,\n }\n }, [fallbackName, options, value])\n\n const selected = useMemo<PickerOption | null>(() => {\n if (!value) return null\n return (\n options.find((option) => option.id === value) ?? {\n id: value,\n name: `Company ${value.slice(-6)}`,\n domain: null,\n }\n )\n }, [options, value])\n /*\n * The list the control searches: every option, plus the placeholder for a\n * stored id the list does not carry, so the selected value is always one\n * of the options and the control never reports it as invalid.\n */\n const listed = useMemo<PickerOption[]>(\n () =>\n selected && !options.some((option) => option.id === selected.id)\n ? [selected, ...options]\n : [...options],\n [options, selected],\n )\n\n const create = useCallback(\n async (name: string) => {\n if (!onCreate) return\n setCreating(true)\n setCreateError('')\n try {\n const company = await onCreate(name)\n onChange(company.id, company)\n } catch (error) {\n setCreateError(\n error instanceof Error && error.message\n ? error.message\n : 'The company could not be created.',\n )\n } finally {\n setCreating(false)\n }\n },\n [onCreate, onChange],\n )\n\n const busy = disabled || !ready || creating\n return (\n <Stack spacing={0.5}>\n <Autocomplete<PickerOption, false, false, false>\n size=\"small\"\n options={listed}\n value={selected}\n disabled={busy}\n loading={!ready}\n getOptionLabel={optionLabel}\n isOptionEqualToValue={(option, current) => option.id === current.id}\n filterOptions={(candidates, state) => {\n const filtered = filterCompanies(candidates, state)\n const typed = state.inputValue.trim()\n const exact = typed.toLowerCase()\n const named = candidates.some(\n (option) =>\n !isCreateOption(option) && option.name.trim().toLowerCase() === exact,\n )\n if (onCreate && typed && !named) {\n filtered.push({ id: CREATE_OPTION_ID, name: typed, create: typed })\n }\n return filtered\n }}\n onChange={(_event, next) => {\n if (!next) return onChange(null, null)\n if (isCreateOption(next)) return void create(next.create)\n onChange(next.id, next)\n }}\n renderInput={(params) => (\n <TextField\n {...params}\n label={label}\n error={Boolean(createError)}\n helperText={\n !ready\n ? 'Loading companies…'\n : creating\n ? 'Creating the company…'\n : createError ||\n helperText ||\n (truncated\n ? `The first ${COMPANY_OPTIONS_LIMIT} by name — type to narrow, or create one.`\n : undefined)\n }\n />\n )}\n />\n {suggestion && suggestion.id !== value ? (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Suggested from the email address: ${suggestion.name}`}\n </Typography>\n <Button\n size=\"small\"\n disabled={busy}\n onClick={() => onChange(suggestion.id, suggestion)}\n >\n {'Use'}\n </Button>\n </Stack>\n ) : fallback ? (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {fallback.match\n ? `Recorded as “${fallback.name}” — a company by that name exists.`\n : `Recorded as “${fallback.name}” without a link.`}\n </Typography>\n {fallback.match ? (\n <Button\n size=\"small\"\n disabled={busy}\n onClick={() => onChange(fallback.match?.id ?? null, fallback.match)}\n >\n {'Use'}\n </Button>\n ) : onCreate ? (\n <Button size=\"small\" disabled={busy} onClick={() => void create(fallback.name)}>\n {`Create “${fallback.name}”`}\n </Button>\n ) : null}\n </Stack>\n ) : null}\n </Stack>\n )\n}\nCompanyPicker.displayName = 'CompanyPicker'\n\nexport default CompanyPicker\n"],"names":["createResourceUid","CRM_COLLECTIONS","CRM_RECORDS_BAND_FULL_MESSAGE","crmNewRecordListFields","useFirestore","useFirestoreCollection","useUser","Autocomplete","Button","createFilterOptions","Stack","TextField","Typography","collection","doc","limit","orderBy","query","serverTimestamp","setDoc","useCallback","useMemo","useState","readCrmRecordsQuota","crmVisibleToClause","useCrmScope","companyDraftFields","EMPTY_COMPANY_DRAFT","suggestCompanyForEmail","COMPANY_OPTIONS_LIMIT","useCompanyOptions","props","hostId","org","enabled","firestore","scope","visibleTo","data","status","companies","idField","options","slice","map","row","id","String","name","domain","ready","Boolean","truncated","length","useCreateCompany","user","createTokens","createHostId","uid","result","ownerUid","ok","Error","error","quota","allowed","set","createdByUid","createdAt","updatedAt","CREATE_OPTION_ID","isCreateOption","option","filterCompanies","stringify","create","optionLabel","CompanyPicker","value","onChange","onCreate","label","helperText","disabled","email","fallbackName","creating","setCreating","createError","setCreateError","suggestion","fallback","trim","lower","toLowerCase","match","find","selected","listed","some","company","message","busy","spacing","size","loading","getOptionLabel","isOptionEqualToValue","current","filterOptions","candidates","state","filtered","typed","inputValue","exact","named","push","_event","next","renderInput","params","undefined","direction","sx","alignItems","variant","color","onClick","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/company-picker.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 AglynOrgBilling,\n createResourceUid,\n CRM_COLLECTIONS,\n CRM_RECORDS_BAND_FULL_MESSAGE,\n crmNewRecordListFields,\n} from '@aglyn/aglyn'\nimport {\n useFirestore,\n useFirestoreCollection,\n useUser,\n} from '@aglyn/tenant-feature-instance'\nimport {\n Autocomplete,\n Button,\n createFilterOptions,\n Stack,\n TextField,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n doc,\n limit,\n orderBy,\n query,\n serverTimestamp,\n setDoc,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useMemo, useState } from 'react'\nimport { readCrmRecordsQuota } from '../hooks/use-crm-records-quota'\nimport { crmVisibleToClause, useCrmScope } from '../hooks/use-crm-scope'\nimport {\n type CompanyOption,\n companyDraftFields,\n EMPTY_COMPANY_DRAFT,\n suggestCompanyForEmail,\n} from '../model/companies'\nimport { useCrmSharingFollowUp } from '../hooks/use-crm-sharing'\n\nexport { suggestCompanyForEmail, type CompanyOption }\n\n/**\n * How many companies the picker lists.\n *\n * A ceiling and not a page: somebody choosing a company knows which one, and\n * a workspace past two hundred accounts finds it from the company list's own\n * search and links the person from there. Bounded because this read happens\n * on a contact's page, where the company list is a control and not the\n * subject.\n */\nexport const COMPANY_OPTIONS_LIMIT = 200\n\nexport interface CompanyOptions {\n options: CompanyOption[]\n /** The listen has answered at least once. */\n ready: boolean\n /** The ceiling cut the list, so a company can exist and not be offered. */\n truncated: boolean\n}\n\n/**\n * The companies this viewer may link a contact to, alphabetically\n * (AGL-2597).\n *\n * Read under the same `visibleTo` predicate the company list runs, because\n * the rules refuse a listen without it for a scoped member — and because a\n * picker offering a company the reader could not open would let them file a\n * person under a record they cannot see. Ordered by `nameLower`, the key\n * every company write stores, so the picker reads as a list of names rather\n * than as document-id order dressed up.\n *\n * `enabled` gates the listen: a contact's properties card has one picker and\n * a merchant who opened the record to read a note must not pay for the\n * company list.\n */\nexport function useCompanyOptions(props: {\n /** The site whose console is reading, or `null` at the organization level. */\n hostId: string | null\n org?: Partial<AglynOrgBilling> | null\n enabled?: boolean\n}): CompanyOptions {\n const { hostId, org, enabled = true } = props\n const firestore = useFirestore()\n const { scope, visibleTo } = useCrmScope({ hostId, org })\n const { data, status } = useFirestoreCollection<Record<string, unknown>>(\n () =>\n enabled && scope\n ? query(\n collection(firestore, scope[0], scope[1], CRM_COLLECTIONS.companies),\n ...crmVisibleToClause(visibleTo),\n orderBy('nameLower'),\n limit(COMPANY_OPTIONS_LIMIT + 1),\n )\n : null,\n [firestore, scope, visibleTo, enabled],\n { idField: '$id' },\n )\n const options = useMemo<CompanyOption[]>(\n () =>\n (data ?? []).slice(0, COMPANY_OPTIONS_LIMIT).map((row) => ({\n id: String(row['$id']),\n name: String(row['name'] ?? ''),\n domain: typeof row['domain'] === 'string' ? row['domain'] : null,\n })),\n [data],\n )\n return {\n options,\n ready: Boolean(scope) && status !== 'loading',\n truncated: (data?.length ?? 0) > COMPANY_OPTIONS_LIMIT,\n }\n}\n\n/**\n * Create a company by name, answering the option the picker then selects.\n * Rejects with the sentence to show when the name cannot be stored.\n */\nexport type CreateCompany = (name: string) => Promise<CompanyOption>\n\n/**\n * The write behind \"Create “Acme”\" in the picker (AGL-2613).\n *\n * The same document the company drawer writes for a create — the search\n * keys, the scope every CRM creator stamps, the provenance — with only the\n * name filled in, because that is all the picker asked for: the domain, the\n * address and the rest are the company page's to add. The person filing it\n * owns it, as the drawer seeds too. `null` until the org has resolved, so a\n * picker offered before the scope is known offers no create.\n *\n * A company is a record of the band the drawers refuse on (AGL-2644): an\n * org at a full hard band is refused here with the drawers' sentence, and\n * the picker shows it beneath the field the way it shows any create that\n * failed. Measured on the click and not on the mount — see\n * `readCrmRecordsQuota` — and never for an edit, because linking an\n * existing company adds no record.\n */\nexport function useCreateCompany(props: {\n /** The site whose console is creating, or `null` at the organization level. */\n hostId: string | null\n org?: Partial<AglynOrgBilling> | null\n}): CreateCompany | null {\n const { hostId, org } = props\n const firestore = useFirestore()\n const { data: user } = useUser()\n // The provenance and the scope both come from the site the record is\n // captured by — the mounted one, or at the organization level the one the\n // reader picked (AGL-2630). No site picked, no create offered.\n const { scope, createTokens, createHostId } = useCrmScope({ hostId, org })\n const followUpSharing = useCrmSharingFollowUp(hostId, scope?.[1] ?? null)\n const uid = user?.uid ?? ''\n return useMemo<CreateCompany | null>(() => {\n if (!scope || !createHostId) return null\n return async (name) => {\n const result = companyDraftFields({\n ...EMPTY_COMPANY_DRAFT,\n name,\n ownerUid: uid,\n })\n if (result.ok === false) throw new Error(result.error)\n const quota = await readCrmRecordsQuota(firestore, scope, org)\n if (!quota.allowed) throw new Error(CRM_RECORDS_BAND_FULL_MESSAGE)\n const id = createResourceUid()\n await setDoc(\n doc(firestore, scope[0], scope[1], CRM_COLLECTIONS.companies, id),\n {\n ...result.set,\n // What the Companies list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('companies', { ...result.set, visibleTo: [...createTokens] }),\n visibleTo: [...createTokens],\n hostId: createHostId,\n createdByUid: uid,\n createdAt: serverTimestamp(),\n updatedAt: serverTimestamp(),\n },\n )\n // A rule may share companies created on this site (AGL-3336).\n followUpSharing('companies', [id])\n return { id, name: String(result.set['name']), domain: null }\n }\n }, [scope, createTokens, firestore, createHostId, uid, org, followUpSharing])\n}\n\n/** The sentinel id of the \"Create …\" row the list grows when nothing matches. */\nconst CREATE_OPTION_ID = '__create__'\n\ntype PickerOption = CompanyOption | { id: typeof CREATE_OPTION_ID; name: string; create: string }\n\nconst isCreateOption = (\n option: PickerOption,\n): option is Extract<PickerOption, { create: string }> => 'create' in option\n\nconst filterCompanies = createFilterOptions<PickerOption>({\n stringify: (option) =>\n isCreateOption(option)\n ? option.create\n : option.domain\n ? `${option.name} ${option.domain}`\n : option.name,\n})\n\nconst optionLabel = (option: PickerOption): string =>\n isCreateOption(option)\n ? `Create “${option.create}”`\n : option.domain\n ? `${option.name} · ${option.domain}`\n : option.name\n\nexport interface CompanyPickerProps {\n options: readonly CompanyOption[]\n /** The linked company's id, or `null` for none. */\n value: string | null\n /**\n * The choice, with the option behind it when the list — or a create — can\n * name it, so a caller can echo the company's name onto the contact\n * without a second lookup. `null` for a company the list does not carry.\n */\n onChange: (companyId: string | null, company: CompanyOption | null) => void\n /**\n * Create a company from the typed name and select it. Absent — or `null`\n * while the scope is unresolved — the picker offers no create row.\n */\n onCreate?: CreateCompany | null\n label?: string\n helperText?: string\n disabled?: boolean\n /**\n * The contact's email address, when the caller has one. A company whose\n * domain matches it is offered as a suggestion beneath the field — a\n * suggestion and never an automatic link, because the person who typed\n * `@acme.com` may be a contractor, a customer of Acme's, or Acme itself.\n */\n email?: string | null\n /**\n * The company name the record carries as free text while no company is\n * linked — an import, or a save from before the picker existed. Offered\n * beneath the field as the company to link or create, so the label a\n * merchant already typed is one click from becoming the record it names.\n */\n fallbackName?: string | null\n /** The option list has answered; until then the field says it is loading. */\n ready?: boolean\n /** The list was cut at the ceiling, so the field says to narrow by typing. */\n truncated?: boolean\n}\n\n/**\n * One company, chosen from the list a contact may be filed under\n * (AGL-2597, AGL-2613).\n *\n * A search over the loaded list rather than free text, because the value is\n * a document id and a typed name is how two records called \"Acme\" come to\n * exist. Typing narrows by name and domain; a name nothing matches grows the\n * list by one row, \"Create “…”\", which writes the company and selects it —\n * so the person filing a contact never has to leave the form to make the\n * account it belongs to. The clear control is the unlink: a saved contact\n * with no company reads as a decision rather than a field somebody skipped.\n *\n * A stored id the list does not carry — a company past the picker's ceiling,\n * or one this viewer cannot see — still renders as SOMETHING selected, or\n * the field would read empty for a contact that has a company and the next\n * save would silently unlink them.\n */\nexport function CompanyPicker(props: CompanyPickerProps) {\n const {\n options,\n value,\n onChange,\n onCreate,\n label = 'Company',\n helperText,\n disabled,\n email,\n fallbackName,\n ready = true,\n truncated,\n } = props\n const [creating, setCreating] = useState(false)\n const [createError, setCreateError] = useState('')\n\n const suggestion = useMemo(\n () => (email ? suggestCompanyForEmail(email, options) : null),\n [email, options],\n )\n const fallback = useMemo(() => {\n const name = String(fallbackName ?? '').trim()\n if (value || !name) return null\n const lower = name.toLowerCase()\n return {\n name,\n match: options.find((option) => option.name.trim().toLowerCase() === lower) ?? null,\n }\n }, [fallbackName, options, value])\n\n const selected = useMemo<PickerOption | null>(() => {\n if (!value) return null\n return (\n options.find((option) => option.id === value) ?? {\n id: value,\n name: `Company ${value.slice(-6)}`,\n domain: null,\n }\n )\n }, [options, value])\n /*\n * The list the control searches: every option, plus the placeholder for a\n * stored id the list does not carry, so the selected value is always one\n * of the options and the control never reports it as invalid.\n */\n const listed = useMemo<PickerOption[]>(\n () =>\n selected && !options.some((option) => option.id === selected.id)\n ? [selected, ...options]\n : [...options],\n [options, selected],\n )\n\n const create = useCallback(\n async (name: string) => {\n if (!onCreate) return\n setCreating(true)\n setCreateError('')\n try {\n const company = await onCreate(name)\n onChange(company.id, company)\n } catch (error) {\n setCreateError(\n error instanceof Error && error.message\n ? error.message\n : 'The company could not be created.',\n )\n } finally {\n setCreating(false)\n }\n },\n [onCreate, onChange],\n )\n\n const busy = disabled || !ready || creating\n return (\n <Stack spacing={0.5}>\n <Autocomplete<PickerOption, false, false, false>\n size=\"small\"\n options={listed}\n value={selected}\n disabled={busy}\n loading={!ready}\n getOptionLabel={optionLabel}\n isOptionEqualToValue={(option, current) => option.id === current.id}\n filterOptions={(candidates, state) => {\n const filtered = filterCompanies(candidates, state)\n const typed = state.inputValue.trim()\n const exact = typed.toLowerCase()\n const named = candidates.some(\n (option) =>\n !isCreateOption(option) && option.name.trim().toLowerCase() === exact,\n )\n if (onCreate && typed && !named) {\n filtered.push({ id: CREATE_OPTION_ID, name: typed, create: typed })\n }\n return filtered\n }}\n onChange={(_event, next) => {\n if (!next) return onChange(null, null)\n if (isCreateOption(next)) return void create(next.create)\n onChange(next.id, next)\n }}\n renderInput={(params) => (\n <TextField\n {...params}\n label={label}\n error={Boolean(createError)}\n helperText={\n !ready\n ? 'Loading companies…'\n : creating\n ? 'Creating the company…'\n : createError ||\n helperText ||\n (truncated\n ? `The first ${COMPANY_OPTIONS_LIMIT} by name — type to narrow, or create one.`\n : undefined)\n }\n />\n )}\n />\n {suggestion && suggestion.id !== value ? (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Suggested from the email address: ${suggestion.name}`}\n </Typography>\n <Button\n size=\"small\"\n disabled={busy}\n onClick={() => onChange(suggestion.id, suggestion)}\n >\n {'Use'}\n </Button>\n </Stack>\n ) : fallback ? (\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {fallback.match\n ? `Recorded as “${fallback.name}” — a company by that name exists.`\n : `Recorded as “${fallback.name}” without a link.`}\n </Typography>\n {fallback.match ? (\n <Button\n size=\"small\"\n disabled={busy}\n onClick={() => onChange(fallback.match?.id ?? null, fallback.match)}\n >\n {'Use'}\n </Button>\n ) : onCreate ? (\n <Button size=\"small\" disabled={busy} onClick={() => void create(fallback.name)}>\n {`Create “${fallback.name}”`}\n </Button>\n ) : null}\n </Stack>\n ) : null}\n </Stack>\n )\n}\nCompanyPicker.displayName = 'CompanyPicker'\n\nexport default CompanyPicker\n"],"names":["createResourceUid","CRM_COLLECTIONS","CRM_RECORDS_BAND_FULL_MESSAGE","crmNewRecordListFields","useFirestore","useFirestoreCollection","useUser","Autocomplete","Button","createFilterOptions","Stack","TextField","Typography","collection","doc","limit","orderBy","query","serverTimestamp","setDoc","useCallback","useMemo","useState","readCrmRecordsQuota","crmVisibleToClause","useCrmScope","companyDraftFields","EMPTY_COMPANY_DRAFT","suggestCompanyForEmail","useCrmSharingFollowUp","COMPANY_OPTIONS_LIMIT","useCompanyOptions","props","hostId","org","enabled","firestore","scope","visibleTo","data","status","companies","idField","options","slice","map","row","id","String","name","domain","ready","Boolean","truncated","length","useCreateCompany","user","createTokens","createHostId","followUpSharing","uid","result","ownerUid","ok","Error","error","quota","allowed","set","createdByUid","createdAt","updatedAt","CREATE_OPTION_ID","isCreateOption","option","filterCompanies","stringify","create","optionLabel","CompanyPicker","value","onChange","onCreate","label","helperText","disabled","email","fallbackName","creating","setCreating","createError","setCreateError","suggestion","fallback","trim","lower","toLowerCase","match","find","selected","listed","some","company","message","busy","spacing","size","loading","getOptionLabel","isOptionEqualToValue","current","filterOptions","candidates","state","filtered","typed","inputValue","exact","named","push","_event","next","renderInput","params","undefined","direction","sx","alignItems","variant","color","onClick","displayName"],"mappings":"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"}
@@ -35,10 +35,10 @@ import { type CrmDetailPageProps } from '../model/crm-routes';
35
35
  * The row is shared by every site that has captured this person, so the
36
36
  * action in the overflow menu drops THIS group's facet, consent, attribution
37
37
  * and scope tokens, and deletes the document only when nobody else is left
38
- * holding it — `planContactDetach` does the counting off `visibleTo`, which
39
- * is what both enforcement layers evaluate, and the rules refuse a delete by
40
- * a caller who is not the sole holder. Not the erasure path: a privacy
41
- * erasure removes the person regardless of who else holds them.
38
+ * holding it. `crm/contact-remove` decides which against the stored
39
+ * document and keeps any marketing refusal the person gave (AGL-3338); the
40
+ * rules refuse a client the write. Not the erasure path: a privacy erasure
41
+ * removes the person regardless of who else holds them.
42
42
  */
43
43
  export declare function ContactDetailPage(props: CrmDetailPageProps): import("react").JSX.Element;
44
44
  export declare namespace ContactDetailPage {
@@ -18,18 +18,19 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
19
  import * as Aglyn from "@aglyn/aglyn";
20
20
  import { CONTACT_LIFECYCLE_STAGE_LABELS, pluginDocsHelp } from "@aglyn/aglyn";
21
+ import { pluginRecordByEmailHref, pluginRecordHref } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
21
22
  import { mdiDeleteOutline, mdiMerge } from "@aglyn/shared-data-mdi";
22
23
  import { AppLink, MdiIcon, useConfirmationContext } from "@aglyn/shared-ui-jsx";
23
24
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
24
- import { useFirestore, useFirestoreDoc, useUser } from "@aglyn/tenant-feature-instance";
25
- import { restampCrmListFields } from "../model/list-fields-api.js";
26
- import { crmTaskCallScope } from "../model/task-routes.js";
25
+ import { useFirestore, useFirestoreDoc } from "@aglyn/tenant-feature-instance";
27
26
  import { Stack, Tooltip, Typography } from "@mui/material";
28
- import { arrayRemove, deleteDoc, deleteField, doc, updateDoc } from "firebase/firestore";
27
+ import { doc } from "firebase/firestore";
29
28
  import { useParams, useRouter } from "next/navigation";
30
29
  import { useCallback, useMemo } from "react";
30
+ import { useContactRemove } from "../hooks/use-contact-remove.js";
31
31
  import { useCrmActivityLogger } from "../hooks/use-crm-activity-logger.js";
32
32
  import { CrmCreateSiteDefault, useCrmOrgMount } from "../hooks/use-crm-org-mount.js";
33
+ import { crmShareChipFor } from "../model/crm-sharing.js";
33
34
  import { useCrmScope } from "../hooks/use-crm-scope.js";
34
35
  import { contactPrimaryGroup, contactRecordFromDoc } from "../model/contact-record.js";
35
36
  import { crmRoutes } from "../model/crm-routes.js";
@@ -51,8 +52,9 @@ import { CrmRecordInsightsZone } from "./crm-record-insights-zone.js";
51
52
  import { CrmSuiteNotice, crmSuiteIncluded, crmSuiteLockedReason } from "./crm-suite-lock.js";
52
53
  import { useErasePersonAction } from "./erase-person-action.js";
53
54
  import RecordFilesCard from "./record-files-card.js";
55
+ import { CrmShareChipView, RecordSharingCard } from "./record-sharing-card.js";
54
56
  import { RecordTasksCard } from "./record-tasks-card.js";
55
- import { useEmailsHubPath } from "./use-emails-hub-path.js";
57
+ import { useSiteRouteContext } from "./use-site-route-context.js";
56
58
  import { useOrgMembers } from "./use-org-members.js";
57
59
  const contactDocsHelp = pluginDocsHelp('contacts', {
58
60
  anchor: '#the-contacts-page',
@@ -94,12 +96,12 @@ const contactDocsHelp = pluginDocsHelp('contacts', {
94
96
  * The row is shared by every site that has captured this person, so the
95
97
  * action in the overflow menu drops THIS group's facet, consent, attribution
96
98
  * and scope tokens, and deletes the document only when nobody else is left
97
- * holding it — `planContactDetach` does the counting off `visibleTo`, which
98
- * is what both enforcement layers evaluate, and the rules refuse a delete by
99
- * a caller who is not the sole holder. Not the erasure path: a privacy
100
- * erasure removes the person regardless of who else holds them.
99
+ * holding it. `crm/contact-remove` decides which against the stored
100
+ * document and keeps any marketing refusal the person gave (AGL-3338); the
101
+ * rules refuse a client the write. Not the erasure path: a privacy erasure
102
+ * removes the person regardless of who else holds them.
101
103
  */ export function ContactDetailPage(props) {
102
- var _ref;
104
+ var _ref, _ref1;
103
105
  const { hostId, org, id, basePath } = props;
104
106
  const routes = crmRoutes(basePath);
105
107
  const router = useRouter();
@@ -169,15 +171,16 @@ const contactDocsHelp = pluginDocsHelp('contacts', {
169
171
  });
170
172
  /*
171
173
  * Where this person's ORDERS are read (AGL-2622): the site's orders list,
172
- * narrowed to their address. The count on the header is the number; the
173
- * list is the rows. Built from the route params already in the URL, so
174
- * no document is read to draw a link, and absent off a site — the
175
- * org-level mount has no orders list to point at.
174
+ * narrowed to their address, at the address the plugin that keeps orders
175
+ * publishes. The count on the header is the number; the list is the rows.
176
+ * Built from the route params already in the URL, so no document is read
177
+ * to draw a link, and absent off a site — the org-level mount has no
178
+ * orders list to point at — and where no plugin keeps orders.
176
179
  */ const params = useParams();
177
- const ordersHref = (params == null ? void 0 : params.orgSlug) && (params == null ? void 0 : params.host) && (record == null ? void 0 : record.email) ? Aglyn.siteRecordLinks({
180
+ const ordersHref = (params == null ? void 0 : params.orgSlug) && (params == null ? void 0 : params.host) && (record == null ? void 0 : record.email) ? pluginRecordByEmailHref('order', {
178
181
  orgSlug: String(params.orgSlug),
179
182
  host: String(params.host)
180
- }).ordersByCustomer(record.email) : null;
183
+ }, record.email) : null;
181
184
  /*
182
185
  * Where this person's BOOKINGS are read (AGL-2660): the site's Bookings
183
186
  * page narrowed to their address, under the same match rule the booking
@@ -187,22 +190,24 @@ const contactDocsHelp = pluginDocsHelp('contacts', {
187
190
  * the subdomain the mount knows it by.
188
191
  */ const bookingDoor = useBookingDoor(siteHostId, org);
189
192
  const siteSlug = (params == null ? void 0 : params.host) ? String(params.host) : siteHostId && mount ? mount.siteSubdomain(siteHostId) : null;
190
- const bookingsHref = bookingDoor.open && (params == null ? void 0 : params.orgSlug) && siteSlug && (record == null ? void 0 : record.email) ? Aglyn.siteRecordLinks({
193
+ const bookingsHref = bookingDoor.open && (params == null ? void 0 : params.orgSlug) && siteSlug && (record == null ? void 0 : record.email) ? pluginRecordByEmailHref('booking', {
191
194
  orgSlug: String(params.orgSlug),
192
195
  host: siteSlug
193
- }).bookingsByBooker(record.email) : null;
196
+ }, record.email) : null;
194
197
  /*
195
198
  * Where a campaign entry on the timeline links to: the email's own report
196
- * on this site's Emails hub (AGL-2616). Only a campaign THIS site sent can
197
- * be addressed — a sibling site in the same consent group has an Emails
198
- * hub of its own under a subdomain this page does not know — so the
199
- * builder answers `null` for those and the entry draws unlinked.
200
- */ const emailsHub = useEmailsHubPath();
201
- const campaignHref = useCallback((email)=>emailsHub && email.hostId === hostId ? `${emailsHub}/messages/${encodeURIComponent(email.campaignId)}` : null, [
202
- emailsHub,
199
+ * (AGL-2616), at the address the plugin that keeps messages publishes as
200
+ * `emailMessage` (AGL-3080). Only a campaign THIS site sent can be
201
+ * addressed — a sibling site in the same consent group has pages of its
202
+ * own under a subdomain this page does not know — so the builder answers
203
+ * `null` for those, and where nothing publishes the kind, and the entry
204
+ * draws unlinked.
205
+ */ const siteRoute = useSiteRouteContext();
206
+ const campaignHref = useCallback((email)=>siteRoute && email.hostId === hostId ? pluginRecordHref('emailMessage', siteRoute, email.campaignId) : null, [
207
+ siteRoute,
203
208
  hostId
204
209
  ]);
205
- const { data: user } = useUser();
210
+ const contactRemove = useContactRemove(hostId);
206
211
  const handleRemove = useCallback(async ()=>{
207
212
  var _ref;
208
213
  if (!row || !scope) return;
@@ -216,26 +221,9 @@ const contactDocsHelp = pluginDocsHelp('contacts', {
216
221
  }).then(()=>true).catch(()=>false);
217
222
  if (!confirmed) return;
218
223
  try {
219
- const ref = doc(firestore, scope[0], scope[1], 'contacts', id);
220
- const plan = Aglyn.planContactDetach(row, consentGroup);
221
- if (plan.action === 'delete') {
222
- await deleteDoc(ref);
223
- } else {
224
- await updateDoc(ref, _extends({}, Object.fromEntries(plan.remove.map((path)=>[
225
- path,
226
- deleteField()
227
- ])), {
228
- visibleTo: arrayRemove(...plan.removeTokens),
229
- capturedByHostIds: arrayRemove(...plan.removeHostIds),
230
- updatedAt: new Date()
231
- }));
232
- // The facet and the scope just let go of are what the Contacts list
233
- // filters and searches by; the rules keep a client from restamping
234
- // them, so the route does (AGL-3321).
235
- await restampCrmListFields(user, crmTaskCallScope(hostId, orgId), 'contacts', [
236
- id
237
- ]);
238
- }
224
+ // The route decides against the stored document, keeps any refusal,
225
+ // and restamps what the Contacts list filters by (AGL-3338).
226
+ const removed = await contactRemove.removeOne(id);
239
227
  // `siteLabel` and not "this site": at the organization level the line
240
228
  // lands in a feed that spans every site, so it has to name the one.
241
229
  logActivity(`Removed contact from ${siteLabel}`, {
@@ -243,7 +231,7 @@ const contactDocsHelp = pluginDocsHelp('contacts', {
243
231
  id,
244
232
  name: (record == null ? void 0 : record.name) || (record == null ? void 0 : record.email)
245
233
  });
246
- enqueueSnackbar(plan.action === 'delete' ? 'Contact deleted' : `Contact removed from ${siteLabel}`, {
234
+ enqueueSnackbar(removed === 'deleted' ? 'Contact deleted' : `Contact removed from ${siteLabel}`, {
247
235
  variant: 'success',
248
236
  persist: false
249
237
  });
@@ -257,9 +245,8 @@ const contactDocsHelp = pluginDocsHelp('contacts', {
257
245
  }
258
246
  }, [
259
247
  confirm,
260
- consentGroup,
248
+ contactRemove,
261
249
  enqueueSnackbar,
262
- firestore,
263
250
  id,
264
251
  logActivity,
265
252
  record,
@@ -267,10 +254,7 @@ const contactDocsHelp = pluginDocsHelp('contacts', {
267
254
  routes,
268
255
  row,
269
256
  scope,
270
- siteLabel,
271
- user,
272
- hostId,
273
- orgId
257
+ siteLabel
274
258
  ]);
275
259
  const overflowItems = [
276
260
  /*
@@ -394,6 +378,10 @@ const contactDocsHelp = pluginDocsHelp('contacts', {
394
378
  /*#__PURE__*/ _jsx(CrmEmailStateChip, {
395
379
  state: record.emailState
396
380
  }),
381
+ /*#__PURE__*/ _jsx(CrmShareChipView, {
382
+ chip: crmShareChipFor(row, (_ref1 = viewingGroup == null ? void 0 : viewingGroup.hostIds) != null ? _ref1 : []),
383
+ org: org
384
+ }),
397
385
  /*#__PURE__*/ _jsx(CrmEmailGatewayChip, {
398
386
  hostId: siteHostId,
399
387
  email: record.email,
@@ -450,6 +438,15 @@ const contactDocsHelp = pluginDocsHelp('contacts', {
450
438
  contactId: id,
451
439
  org: org
452
440
  }) : null,
441
+ /*#__PURE__*/ _jsx(RecordSharingCard, {
442
+ object: "contacts",
443
+ id: id,
444
+ record: row,
445
+ hostId: hostId != null ? hostId : null,
446
+ orgId: orgId,
447
+ org: org,
448
+ viewingHostIds: viewingGroup == null ? void 0 : viewingGroup.hostIds
449
+ }),
453
450
  /*#__PURE__*/ _jsx(ContactPropertiesCard, {
454
451
  hostId: hostId,
455
452
  org: org,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-detail-page.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 { CONTACT_LIFECYCLE_STAGE_LABELS, pluginDocsHelp } from '@aglyn/aglyn'\nimport { mdiDeleteOutline, mdiMerge } from '@aglyn/shared-data-mdi'\nimport { AppLink, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useFirestore, useFirestoreDoc, useUser } from '@aglyn/tenant-feature-instance'\nimport { restampCrmListFields } from '../model/list-fields-api'\nimport { crmTaskCallScope } from '../model/task-routes'\nimport { Stack, Tooltip, Typography } from '@mui/material'\nimport {\n arrayRemove,\n deleteDoc,\n deleteField,\n doc,\n updateDoc,\n} from 'firebase/firestore'\nimport { useParams, useRouter } from 'next/navigation'\nimport { useCallback, useMemo } from 'react'\nimport { useCrmActivityLogger } from '../hooks/use-crm-activity-logger'\nimport { CrmCreateSiteDefault, useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport { useCrmScope } from '../hooks/use-crm-scope'\nimport { contactPrimaryGroup, contactRecordFromDoc } from '../model/contact-record'\nimport { type CrmDetailPageProps, crmRoutes } from '../model/crm-routes'\nimport ContactAssociationsCard from './contact-associations-card'\nimport ContactDuplicatesCard from './contact-duplicates-card'\nimport ContactKnownByCard from './contact-known-by-card'\nimport ContactMergeDialog, { useContactMergeDialog } from './contact-merge-dialog'\nimport ContactPropertiesCard from './contact-properties-card'\nimport ContactTimelineCard from './contact-timeline-card'\nimport { AddToListButton } from './add-to-list-button'\nimport { useBookingDoor } from './crm-booking-zone'\nimport { CrmSendEmailButton } from './crm-send-email-button'\nimport { ContactDealsCard } from './contact-deals-card'\nimport { CrmCallButton } from './crm-call-actions'\nimport { CrmEmailStateChip } from './crm-email-state-chip'\nimport { CrmEmailGatewayChip } from './crm-email-check'\nimport { CrmRecordChip, CrmRecordHeader } from './crm-record-header'\nimport { CrmRecordInsightsZone } from './crm-record-insights-zone'\nimport { CrmSuiteNotice, crmSuiteIncluded, crmSuiteLockedReason } from './crm-suite-lock'\nimport { useErasePersonAction } from './erase-person-action'\nimport RecordFilesCard from './record-files-card'\nimport { RecordTasksCard } from './record-tasks-card'\nimport { useEmailsHubPath } from './use-emails-hub-path'\nimport { useOrgMembers } from './use-org-members'\n\nconst contactDocsHelp = pluginDocsHelp('contacts', {\n anchor: '#the-contacts-page',\n excerpt:\n \"One person's record: the profile your team keeps on them, where they \" +\n 'came from, what they are filed under, and what the site recorded.',\n})\n\n/**\n * `/crm/contacts/{contactId}` — one person (AGL-2596).\n *\n * A ROUTE rather than the v1 drawer, because a person is the thing every\n * other CRM record points at: a deal, a task and an activity all name a\n * contact, and a link that opens a drawer on top of a list is not a link\n * anybody can paste.\n *\n * ## What this page reads\n *\n * ONE document, by id, through the scoped rules — a single-document get is\n * judged on that document's own `visibleTo`, so no `array-contains-any`\n * filter is needed here the way it is on the list. The row is then flattened\n * through the viewing group's facet ONCE (`contactRecordFromDoc`) and the\n * cards below read the flat record: none of them reaches into\n * `facets.{groupId}` for itself, which is what keeps a card from ever\n * showing another holder's notes.\n *\n * Beyond the document: the team roster (one request, for the owner picker)\n * and the site's campaigns (one listen, for the filing picker) — both paid\n * here because opening a record IS the ask for them, and neither is paid by\n * the list.\n *\n * ## The cards\n *\n * Each in its own file, so a card can grow without this page growing:\n * properties (the editable profile, with the org's custom fields under it),\n * the relationship (sources, attribution, consent, campaign filing) and the\n * timeline. Other CRM sections add a card here by inserting one import and\n * one line.\n *\n * ## Delete is a detach\n *\n * The row is shared by every site that has captured this person, so the\n * action in the overflow menu drops THIS group's facet, consent, attribution\n * and scope tokens, and deletes the document only when nobody else is left\n * holding it — `planContactDetach` does the counting off `visibleTo`, which\n * is what both enforcement layers evaluate, and the rules refuse a delete by\n * a caller who is not the sole holder. Not the erasure path: a privacy\n * erasure removes the person regardless of who else holds them.\n */\nexport function ContactDetailPage(props: CrmDetailPageProps) {\n const { hostId, org, id, basePath } = props\n const routes = crmRoutes(basePath)\n const router = useRouter()\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // The org root, the viewing group and what this viewer may list — the\n // merge search's and the duplicates query's one clause — from the one\n // scope hook (AGL-2614); at the organization level the group is `null`\n // and resolved per person below, and the listeners carry no clause\n // (AGL-2630).\n const {\n scope,\n orgId,\n consentGroup: viewingGroup,\n visibleTo,\n } = useCrmScope({ hostId, org })\n const mount = useCrmOrgMount()\n const merge = useContactMergeDialog()\n /*\n * THE CRM (AGL-2788). This page is the CRM's, and the shell mounts no CRM\n * page for a plan without it (AGL-2851). What locks below — owner, stage\n * and company; tasks, deals, files and custom fields; a logged call or\n * activity; one-to-one email; a merge — draws only for an org whose plan\n * lacks the CRM. The shell mounted this page once the org had settled, so\n * this is the plan, not a paint of the org still loading.\n */\n const suiteIncluded = crmSuiteIncluded(org)\n\n const {\n data: row,\n status,\n fromCache,\n } = useFirestoreDoc<Record<string, any>>(\n () => (scope ? doc(firestore, scope[0], scope[1], 'contacts', id) : null),\n [firestore, scope, id],\n { idField: '$id' },\n )\n /*\n * The controller this page is showing. Under a site: the sites declared\n * to be one sender, or this site alone — resolved from the org document\n * the shell already passed, so it costs no read. At the ORGANIZATION\n * level: the person's own PRIMARY holder, the first capturing site's\n * group, so an org-wide member reads the profile that site keeps and an\n * edit goes back to the same facet; the \"Known by\" card says which other\n * sites hold one.\n */\n const consentGroup = useMemo(\n () => viewingGroup ?? contactPrimaryGroup(row, org as Record<string, unknown>),\n [viewingGroup, row, org],\n )\n /**\n * The SITE the page acts as, for what needs one — the lead lookup, the\n * campaign filing, the site's activity feed: the mounted site, or the\n * primary holder's, or nothing for a row no site has captured.\n */\n const siteHostId = hostId ?? (consentGroup.hostId || null)\n /*\n * The feed the act is logged in, decided by the level it was performed\n * at (AGL-2738) and so given the MOUNTED site rather than `siteHostId`:\n * at the org hub a client-direct append to the holder's own site would\n * face a gate the record write never had to pass.\n */\n const logActivity = useCrmActivityLogger(hostId)\n const record = useMemo(\n () => (row ? contactRecordFromDoc(row, consentGroup) : null),\n [row, consentGroup],\n )\n const notFound = Boolean(scope) && status !== 'loading' && !row\n /** How the site the delete detaches from is named — see `handleRemove`. */\n const siteLabel = hostId\n ? 'this site'\n : siteHostId\n ? (mount?.siteName(siteHostId) ?? siteHostId)\n : 'its site'\n\n // The roster, for the owner picker and the owner's name — read because a\n // record page is the one place both are shown.\n const members = useOrgMembers(orgId, { enabled: Boolean(record) })\n /*\n * Where this person's ORDERS are read (AGL-2622): the site's orders list,\n * narrowed to their address. The count on the header is the number; the\n * list is the rows. Built from the route params already in the URL, so\n * no document is read to draw a link, and absent off a site — the\n * org-level mount has no orders list to point at.\n */\n const params = useParams<{ orgSlug?: string; host?: string }>()\n const ordersHref =\n params?.orgSlug && params?.host && record?.email\n ? Aglyn.siteRecordLinks({\n orgSlug: String(params.orgSlug),\n host: String(params.host),\n }).ordersByCustomer(record.email)\n : null\n /*\n * Where this person's BOOKINGS are read (AGL-2660): the site's Bookings\n * page narrowed to their address, under the same match rule the booking\n * row's own \"View in CRM\" uses. Offered only where the site has a booking\n * door — a site that never enabled Bookings should not carry the row. At\n * the organization level the capturing site's page, addressed through\n * the subdomain the mount knows it by.\n */\n const bookingDoor = useBookingDoor(siteHostId, org)\n const siteSlug = params?.host\n ? String(params.host)\n : siteHostId && mount\n ? mount.siteSubdomain(siteHostId)\n : null\n const bookingsHref =\n bookingDoor.open && params?.orgSlug && siteSlug && record?.email\n ? Aglyn.siteRecordLinks({\n orgSlug: String(params.orgSlug),\n host: siteSlug,\n }).bookingsByBooker(record.email)\n : null\n\n /*\n * Where a campaign entry on the timeline links to: the email's own report\n * on this site's Emails hub (AGL-2616). Only a campaign THIS site sent can\n * be addressed — a sibling site in the same consent group has an Emails\n * hub of its own under a subdomain this page does not know — so the\n * builder answers `null` for those and the entry draws unlinked.\n */\n const emailsHub = useEmailsHubPath()\n const campaignHref = useCallback(\n (email: Aglyn.ContactCampaignEmail) =>\n emailsHub && email.hostId === hostId\n ? `${emailsHub}/messages/${encodeURIComponent(email.campaignId)}`\n : null,\n [emailsHub, hostId],\n )\n\n const { data: user } = useUser()\n const handleRemove = useCallback(async () => {\n if (!row || !scope) return\n const confirmed = await confirm({\n title: 'Delete this contact?',\n description:\n `\"${record?.email ?? id}\" is removed from ${siteLabel}'s ` +\n 'Contacts, along with its notes, tags and timeline. Other sites ' +\n 'that captured the same person keep their own records. Their form ' +\n 'submissions, orders, bookings, and membership records are ' +\n 'separate — delete those from their own pages if the request ' +\n 'covers them.',\n confirmationText: 'Delete contact',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n try {\n const ref = doc(firestore, scope[0], scope[1], 'contacts', id)\n const plan = Aglyn.planContactDetach(row, consentGroup)\n if (plan.action === 'delete') {\n await deleteDoc(ref)\n } else {\n await updateDoc(ref, {\n ...Object.fromEntries(plan.remove.map((path) => [path, deleteField()])),\n visibleTo: arrayRemove(...plan.removeTokens),\n capturedByHostIds: arrayRemove(...plan.removeHostIds),\n updatedAt: new Date(),\n })\n // The facet and the scope just let go of are what the Contacts list\n // filters and searches by; the rules keep a client from restamping\n // them, so the route does (AGL-3321).\n await restampCrmListFields(user, crmTaskCallScope(hostId, orgId), 'contacts', [id])\n }\n // `siteLabel` and not \"this site\": at the organization level the line\n // lands in a feed that spans every site, so it has to name the one.\n logActivity(`Removed contact from ${siteLabel}`, {\n type: 'contact',\n id,\n name: record?.name || record?.email,\n })\n enqueueSnackbar(\n plan.action === 'delete'\n ? 'Contact deleted'\n : `Contact removed from ${siteLabel}`,\n { variant: 'success', persist: false },\n )\n router.push(routes.section('contacts'))\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', {\n variant: 'error',\n allowDuplicate: true,\n })\n }\n }, [\n confirm,\n consentGroup,\n enqueueSnackbar,\n firestore,\n id,\n logActivity,\n record,\n router,\n routes,\n row,\n scope,\n siteLabel,\n user,\n hostId,\n orgId,\n ])\n\n const overflowItems: RowActionsMenuItem[] = [\n /*\n * Two records for one person become one (AGL-2625). Opened empty, to be\n * searched, with the OTHER record surviving: a reader on the record they\n * want gone says \"merge this into…\". The dialog lets them switch.\n */\n {\n key: 'merge',\n label: 'Merge into…',\n icon: <MdiIcon path={mdiMerge.path} size={0.8} />,\n disabled: !row || !suiteIncluded,\n disabledReason: suiteIncluded ? 'The contact has not loaded' : crmSuiteLockedReason(),\n onClick: () => merge.open(),\n },\n {\n key: 'remove',\n label: 'Delete contact',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n disabled: !row,\n disabledReason: 'The contact has not loaded',\n onClick: () => void handleRemove(),\n },\n ]\n // The privacy erasure (AGL-2623): its item joins the overflow, its banner\n // sits under the header while a request waits, its dialog renders once.\n const erase = useErasePersonAction({\n // Filed as the site the page acts as — at the organization level the\n // person's primary holder (AGL-2630).\n hostId: siteHostId,\n orgId,\n subject: record ? { kind: 'contact', id, email: record.email } : null,\n requestedAtMs: Aglyn.readErasureRequestedAtMs(row),\n })\n\n if (notFound) {\n return (\n <CrmRecordHeader\n kind=\"Contact\"\n title={undefined}\n help={contactDocsHelp}\n backHref={routes.section('contacts')}\n backLabel=\"Back to contacts\"\n >\n {/*\n * Not \"no data\". A contact that cannot be read is a different\n * situation from one with an empty profile — it may have been\n * removed from this site, or be held only by a site this reader\n * cannot see.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This contact could not be loaded. It may have been removed from this site.'}\n </Typography>\n </CrmRecordHeader>\n )\n }\n\n return (\n // A create opened from this page — a task, an activity, a deal — files\n // under the site that captured this person unless the reader says\n // otherwise (AGL-2630).\n <CrmCreateSiteDefault hostId={siteHostId}>\n <Stack spacing={3}>\n <CrmRecordHeader\n kind=\"Contact\"\n title={record ? record.name || record.email : undefined}\n // The identity, and the other addresses a merge folded into it.\n subtitle={\n record\n ? [record.email, ...record.alternateEmails.map((email) => `also ${email}`)].join(' · ')\n : undefined\n }\n help={contactDocsHelp}\n backHref={routes.section('contacts')}\n backLabel=\"Back to contacts\"\n actions={\n record ? (\n <>\n <AddToListButton hostId={hostId} org={org} contactId={id} email={record.email} />\n {/* Dial the number, and log the call it was (AGL-2661). */}\n <CrmCallButton\n hostId={siteHostId}\n org={org}\n link={{ contactId: id }}\n phone={record.phone}\n suiteLocked={!suiteIncluded}\n />\n <CrmSendEmailButton\n hostId={siteHostId}\n org={org}\n contactId={id}\n email={record.email}\n name={record.name}\n emailState={record.emailState}\n suiteLocked={!suiteIncluded}\n />\n </>\n ) : null\n }\n menuItems={[...overflowItems, ...erase.menuItems]}\n // The booking door (AGL-2660): this site's services, or at the\n // organization level the capturing site's.\n booking={record ? { hostId: siteHostId, org, kind: 'contact', recordId: id } : undefined}\n loading={!record}\n chips={\n record ? (\n <>\n {/* The verdict on the address (AGL-3245), first: a bounce or a\n do-not-contact mark is what a person deciding to write must see. */}\n <CrmEmailStateChip state={record.emailState} />\n {/* The mail gateway in front of the address (AGL-3328). */}\n <CrmEmailGatewayChip\n hostId={siteHostId}\n email={record.email}\n emailState={record.emailState}\n enabled={suiteIncluded}\n />\n <CrmRecordChip\n label=\"Stage\"\n value={\n record.lifecycleStage\n ? CONTACT_LIFECYCLE_STAGE_LABELS[record.lifecycleStage]\n : undefined\n }\n />\n <CrmRecordChip\n label=\"Owner\"\n value={record.ownerUid ? members.memberName(record.ownerUid) : undefined}\n />\n {/*\n The last time they opened or clicked one of this site's\n campaigns (AGL-2616) — the relationship's pulse, beside the\n owner who keeps it.\n */}\n <CrmRecordChip\n label=\"Last engaged\"\n value={\n record.lastEmailEngagementAtMs ? (\n <Tooltip title={new Date(record.lastEmailEngagementAtMs).toLocaleString()}>\n <span>\n {Aglyn.activityTimeLabel(record.lastEmailEngagementAtMs, Date.now())}\n </span>\n </Tooltip>\n ) : undefined\n }\n />\n <CrmRecordChip\n label=\"Orders\"\n value={\n record.ordersCount > 0 ? (\n ordersHref ? (\n <AppLink href={ordersHref} color=\"inherit\" underline=\"hover\">\n {`${record.ordersCount.toLocaleString()} · $${(record.ltvCents / 100).toFixed(2)} lifetime`}\n </AppLink>\n ) : (\n `${record.ordersCount.toLocaleString()} · $${(record.ltvCents / 100).toFixed(2)} lifetime`\n )\n ) : undefined\n }\n />\n </>\n ) : null\n }\n >\n {erase.banner}\n </CrmRecordHeader>\n {record && row && scope ? (\n <>\n {suiteIncluded ? null : (\n <CrmSuiteNotice>\n {'Working this contact — the owner, stage and company, tasks, ' +\n 'deals, files and custom fields, logging a call or an activity, ' +\n 'one-to-one email and merging duplicates — is part of the CRM.'}\n </CrmSuiteNotice>\n )}\n {/*\n The cross-site fact, at the organization level only (AGL-2630):\n which sites know this person, and their consent for each. Under\n a site the page IS one site's view and the card would say so on\n every record.\n */}\n {/* What an assistant proposes about the person (AGL-2917), where the plan works a contact. */}\n {suiteIncluded ? (\n <CrmRecordInsightsZone\n hostId={hostId}\n org={org as Record<string, unknown> | undefined}\n kind=\"contact\"\n recordId={id}\n name={record.name || record.email}\n taskLink={{ contactId: id }}\n />\n ) : null}\n {mount ? <ContactKnownByCard row={row} contactId={id} org={org} /> : null}\n <ContactPropertiesCard\n hostId={hostId}\n org={org}\n record={record}\n orgId={orgId}\n consentGroup={consentGroup}\n seed={{ status, fromCache }}\n members={members}\n suiteLocked={!suiteIncluded}\n />\n <ContactAssociationsCard\n hostId={siteHostId}\n record={record}\n row={row}\n consentGroup={consentGroup}\n seed={{ status, fromCache }}\n basePath={basePath}\n bookingsHref={bookingsHref}\n />\n <ContactTimelineCard\n hostId={hostId}\n org={org}\n contactId={id}\n contact={row}\n campaignHref={campaignHref}\n suiteLocked={!suiteIncluded}\n />\n {/*\n On a plan without the CRM, the cards that only work a record —\n deals, tasks, files — are named in the notice above rather than\n drawn as upsells in a row.\n */}\n {suiteIncluded ? (\n <>\n <ContactDealsCard\n hostId={hostId}\n org={org}\n basePath={basePath}\n contactId={id}\n contactName={record.name || record.email}\n />\n <RecordTasksCard hostId={hostId} org={org} basePath={basePath} contactId={id} />\n {/*\n The files are the VIEWING holder's, like everything else on this\n page: the write is a facet path, so a contract one brand filed\n never appears on a sibling brand's copy of the person.\n */}\n <RecordFilesCard\n scope={scope}\n collection=\"contacts\"\n recordId={id}\n hostId={hostId}\n facetGroupId={consentGroup.groupId}\n mediaIds={record.mediaIds}\n topic=\"contactRecord\"\n />\n </>\n ) : null}\n <ContactDuplicatesCard\n current={{ id, doc: row }}\n scope={scope}\n consentGroup={consentGroup}\n visibleTo={visibleTo}\n basePath={basePath}\n onMerge={(candidate) => merge.open({ other: candidate, keep: 'current' })}\n suiteLocked={!suiteIncluded}\n />\n <ContactMergeDialog\n {...merge.state}\n onClose={merge.close}\n hostId={siteHostId}\n current={{ id, doc: row }}\n scope={scope}\n consentGroup={consentGroup}\n visibleTo={visibleTo}\n memberName={members.memberName}\n // The page's own record was folded away: its address now opens\n // the survivor, which is where the reader is taken.\n onMerged={(survivorId) => {\n if (survivorId !== id) router.replace(routes.contact(survivorId))\n }}\n />\n </>\n ) : null}\n {erase.dialog}\n </Stack>\n </CrmCreateSiteDefault>\n )\n}\nContactDetailPage.displayName = 'ContactDetailPage'\n\nexport default ContactDetailPage\n"],"names":["Aglyn","CONTACT_LIFECYCLE_STAGE_LABELS","pluginDocsHelp","mdiDeleteOutline","mdiMerge","AppLink","MdiIcon","useConfirmationContext","useSnackbar","useFirestore","useFirestoreDoc","useUser","restampCrmListFields","crmTaskCallScope","Stack","Tooltip","Typography","arrayRemove","deleteDoc","deleteField","doc","updateDoc","useParams","useRouter","useCallback","useMemo","useCrmActivityLogger","CrmCreateSiteDefault","useCrmOrgMount","useCrmScope","contactPrimaryGroup","contactRecordFromDoc","crmRoutes","ContactAssociationsCard","ContactDuplicatesCard","ContactKnownByCard","ContactMergeDialog","useContactMergeDialog","ContactPropertiesCard","ContactTimelineCard","AddToListButton","useBookingDoor","CrmSendEmailButton","ContactDealsCard","CrmCallButton","CrmEmailStateChip","CrmEmailGatewayChip","CrmRecordChip","CrmRecordHeader","CrmRecordInsightsZone","CrmSuiteNotice","crmSuiteIncluded","crmSuiteLockedReason","useErasePersonAction","RecordFilesCard","RecordTasksCard","useEmailsHubPath","useOrgMembers","contactDocsHelp","anchor","excerpt","ContactDetailPage","props","hostId","org","id","basePath","routes","router","firestore","enqueueSnackbar","confirm","scope","orgId","consentGroup","viewingGroup","visibleTo","mount","merge","suiteIncluded","data","row","status","fromCache","idField","siteHostId","logActivity","record","notFound","Boolean","siteLabel","siteName","members","enabled","params","ordersHref","orgSlug","host","email","siteRecordLinks","String","ordersByCustomer","bookingDoor","siteSlug","siteSubdomain","bookingsHref","open","bookingsByBooker","emailsHub","campaignHref","encodeURIComponent","campaignId","user","handleRemove","confirmed","title","description","confirmationText","confirmationButtonProps","color","then","catch","ref","plan","planContactDetach","action","Object","fromEntries","remove","map","path","removeTokens","capturedByHostIds","removeHostIds","updatedAt","Date","type","name","variant","persist","push","section","error","console","allowDuplicate","overflowItems","key","label","icon","size","disabled","disabledReason","onClick","destructive","erase","subject","kind","requestedAtMs","readErasureRequestedAtMs","undefined","help","backHref","backLabel","spacing","subtitle","alternateEmails","join","actions","contactId","link","phone","suiteLocked","emailState","menuItems","booking","recordId","loading","chips","state","value","lifecycleStage","ownerUid","memberName","lastEmailEngagementAtMs","toLocaleString","span","activityTimeLabel","now","ordersCount","href","underline","ltvCents","toFixed","banner","taskLink","seed","contact","contactName","collection","facetGroupId","groupId","mediaIds","topic","current","onMerge","candidate","other","keep","onClose","close","onMerged","survivorId","replace","dialog","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-detail-page.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 { CONTACT_LIFECYCLE_STAGE_LABELS, pluginDocsHelp } from '@aglyn/aglyn'\nimport {\n pluginRecordByEmailHref,\n pluginRecordHref,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { mdiDeleteOutline, mdiMerge } from '@aglyn/shared-data-mdi'\nimport { AppLink, MdiIcon, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport type { RowActionsMenuItem } from '@aglyn/shared-ui-jsx/components/row-actions-menu.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useFirestore, useFirestoreDoc } from '@aglyn/tenant-feature-instance'\nimport { Stack, Tooltip, Typography } from '@mui/material'\nimport { doc } from 'firebase/firestore'\nimport { useParams, useRouter } from 'next/navigation'\nimport { useCallback, useMemo } from 'react'\nimport { useContactRemove } from '../hooks/use-contact-remove'\nimport { useCrmActivityLogger } from '../hooks/use-crm-activity-logger'\nimport { CrmCreateSiteDefault, useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport { crmShareChipFor } from '../model/crm-sharing'\nimport { useCrmScope } from '../hooks/use-crm-scope'\nimport { contactPrimaryGroup, contactRecordFromDoc } from '../model/contact-record'\nimport { type CrmDetailPageProps, crmRoutes } from '../model/crm-routes'\nimport ContactAssociationsCard from './contact-associations-card'\nimport ContactDuplicatesCard from './contact-duplicates-card'\nimport ContactKnownByCard from './contact-known-by-card'\nimport ContactMergeDialog, { useContactMergeDialog } from './contact-merge-dialog'\nimport ContactPropertiesCard from './contact-properties-card'\nimport ContactTimelineCard from './contact-timeline-card'\nimport { AddToListButton } from './add-to-list-button'\nimport { useBookingDoor } from './crm-booking-zone'\nimport { CrmSendEmailButton } from './crm-send-email-button'\nimport { ContactDealsCard } from './contact-deals-card'\nimport { CrmCallButton } from './crm-call-actions'\nimport { CrmEmailStateChip } from './crm-email-state-chip'\nimport { CrmEmailGatewayChip } from './crm-email-check'\nimport { CrmRecordChip, CrmRecordHeader } from './crm-record-header'\nimport { CrmRecordInsightsZone } from './crm-record-insights-zone'\nimport { CrmSuiteNotice, crmSuiteIncluded, crmSuiteLockedReason } from './crm-suite-lock'\nimport { useErasePersonAction } from './erase-person-action'\nimport RecordFilesCard from './record-files-card'\nimport { CrmShareChipView, RecordSharingCard } from './record-sharing-card'\nimport { RecordTasksCard } from './record-tasks-card'\nimport { useSiteRouteContext } from './use-site-route-context'\nimport { useOrgMembers } from './use-org-members'\n\nconst contactDocsHelp = pluginDocsHelp('contacts', {\n anchor: '#the-contacts-page',\n excerpt:\n \"One person's record: the profile your team keeps on them, where they \" +\n 'came from, what they are filed under, and what the site recorded.',\n})\n\n/**\n * `/crm/contacts/{contactId}` — one person (AGL-2596).\n *\n * A ROUTE rather than the v1 drawer, because a person is the thing every\n * other CRM record points at: a deal, a task and an activity all name a\n * contact, and a link that opens a drawer on top of a list is not a link\n * anybody can paste.\n *\n * ## What this page reads\n *\n * ONE document, by id, through the scoped rules — a single-document get is\n * judged on that document's own `visibleTo`, so no `array-contains-any`\n * filter is needed here the way it is on the list. The row is then flattened\n * through the viewing group's facet ONCE (`contactRecordFromDoc`) and the\n * cards below read the flat record: none of them reaches into\n * `facets.{groupId}` for itself, which is what keeps a card from ever\n * showing another holder's notes.\n *\n * Beyond the document: the team roster (one request, for the owner picker)\n * and the site's campaigns (one listen, for the filing picker) — both paid\n * here because opening a record IS the ask for them, and neither is paid by\n * the list.\n *\n * ## The cards\n *\n * Each in its own file, so a card can grow without this page growing:\n * properties (the editable profile, with the org's custom fields under it),\n * the relationship (sources, attribution, consent, campaign filing) and the\n * timeline. Other CRM sections add a card here by inserting one import and\n * one line.\n *\n * ## Delete is a detach\n *\n * The row is shared by every site that has captured this person, so the\n * action in the overflow menu drops THIS group's facet, consent, attribution\n * and scope tokens, and deletes the document only when nobody else is left\n * holding it. `crm/contact-remove` decides which against the stored\n * document and keeps any marketing refusal the person gave (AGL-3338); the\n * rules refuse a client the write. Not the erasure path: a privacy erasure\n * removes the person regardless of who else holds them.\n */\nexport function ContactDetailPage(props: CrmDetailPageProps) {\n const { hostId, org, id, basePath } = props\n const routes = crmRoutes(basePath)\n const router = useRouter()\n const firestore = useFirestore()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n // The org root, the viewing group and what this viewer may list — the\n // merge search's and the duplicates query's one clause — from the one\n // scope hook (AGL-2614); at the organization level the group is `null`\n // and resolved per person below, and the listeners carry no clause\n // (AGL-2630).\n const {\n scope,\n orgId,\n consentGroup: viewingGroup,\n visibleTo,\n } = useCrmScope({ hostId, org })\n const mount = useCrmOrgMount()\n const merge = useContactMergeDialog()\n /*\n * THE CRM (AGL-2788). This page is the CRM's, and the shell mounts no CRM\n * page for a plan without it (AGL-2851). What locks below — owner, stage\n * and company; tasks, deals, files and custom fields; a logged call or\n * activity; one-to-one email; a merge — draws only for an org whose plan\n * lacks the CRM. The shell mounted this page once the org had settled, so\n * this is the plan, not a paint of the org still loading.\n */\n const suiteIncluded = crmSuiteIncluded(org)\n\n const {\n data: row,\n status,\n fromCache,\n } = useFirestoreDoc<Record<string, any>>(\n () => (scope ? doc(firestore, scope[0], scope[1], 'contacts', id) : null),\n [firestore, scope, id],\n { idField: '$id' },\n )\n /*\n * The controller this page is showing. Under a site: the sites declared\n * to be one sender, or this site alone — resolved from the org document\n * the shell already passed, so it costs no read. At the ORGANIZATION\n * level: the person's own PRIMARY holder, the first capturing site's\n * group, so an org-wide member reads the profile that site keeps and an\n * edit goes back to the same facet; the \"Known by\" card says which other\n * sites hold one.\n */\n const consentGroup = useMemo(\n () => viewingGroup ?? contactPrimaryGroup(row, org as Record<string, unknown>),\n [viewingGroup, row, org],\n )\n /**\n * The SITE the page acts as, for what needs one — the lead lookup, the\n * campaign filing, the site's activity feed: the mounted site, or the\n * primary holder's, or nothing for a row no site has captured.\n */\n const siteHostId = hostId ?? (consentGroup.hostId || null)\n /*\n * The feed the act is logged in, decided by the level it was performed\n * at (AGL-2738) and so given the MOUNTED site rather than `siteHostId`:\n * at the org hub a client-direct append to the holder's own site would\n * face a gate the record write never had to pass.\n */\n const logActivity = useCrmActivityLogger(hostId)\n const record = useMemo(\n () => (row ? contactRecordFromDoc(row, consentGroup) : null),\n [row, consentGroup],\n )\n const notFound = Boolean(scope) && status !== 'loading' && !row\n /** How the site the delete detaches from is named — see `handleRemove`. */\n const siteLabel = hostId\n ? 'this site'\n : siteHostId\n ? (mount?.siteName(siteHostId) ?? siteHostId)\n : 'its site'\n\n // The roster, for the owner picker and the owner's name — read because a\n // record page is the one place both are shown.\n const members = useOrgMembers(orgId, { enabled: Boolean(record) })\n /*\n * Where this person's ORDERS are read (AGL-2622): the site's orders list,\n * narrowed to their address, at the address the plugin that keeps orders\n * publishes. The count on the header is the number; the list is the rows.\n * Built from the route params already in the URL, so no document is read\n * to draw a link, and absent off a site — the org-level mount has no\n * orders list to point at — and where no plugin keeps orders.\n */\n const params = useParams<{ orgSlug?: string; host?: string }>()\n const ordersHref =\n params?.orgSlug && params?.host && record?.email\n ? pluginRecordByEmailHref(\n 'order',\n { orgSlug: String(params.orgSlug), host: String(params.host) },\n record.email,\n )\n : null\n /*\n * Where this person's BOOKINGS are read (AGL-2660): the site's Bookings\n * page narrowed to their address, under the same match rule the booking\n * row's own \"View in CRM\" uses. Offered only where the site has a booking\n * door — a site that never enabled Bookings should not carry the row. At\n * the organization level the capturing site's page, addressed through\n * the subdomain the mount knows it by.\n */\n const bookingDoor = useBookingDoor(siteHostId, org)\n const siteSlug = params?.host\n ? String(params.host)\n : siteHostId && mount\n ? mount.siteSubdomain(siteHostId)\n : null\n const bookingsHref =\n bookingDoor.open && params?.orgSlug && siteSlug && record?.email\n ? pluginRecordByEmailHref(\n 'booking',\n { orgSlug: String(params.orgSlug), host: siteSlug },\n record.email,\n )\n : null\n\n /*\n * Where a campaign entry on the timeline links to: the email's own report\n * (AGL-2616), at the address the plugin that keeps messages publishes as\n * `emailMessage` (AGL-3080). Only a campaign THIS site sent can be\n * addressed — a sibling site in the same consent group has pages of its\n * own under a subdomain this page does not know — so the builder answers\n * `null` for those, and where nothing publishes the kind, and the entry\n * draws unlinked.\n */\n const siteRoute = useSiteRouteContext()\n const campaignHref = useCallback(\n (email: Aglyn.ContactCampaignEmail) =>\n siteRoute && email.hostId === hostId\n ? pluginRecordHref('emailMessage', siteRoute, email.campaignId)\n : null,\n [siteRoute, hostId],\n )\n\n const contactRemove = useContactRemove(hostId)\n const handleRemove = useCallback(async () => {\n if (!row || !scope) return\n const confirmed = await confirm({\n title: 'Delete this contact?',\n description:\n `\"${record?.email ?? id}\" is removed from ${siteLabel}'s ` +\n 'Contacts, along with its notes, tags and timeline. Other sites ' +\n 'that captured the same person keep their own records. Their form ' +\n 'submissions, orders, bookings, and membership records are ' +\n 'separate — delete those from their own pages if the request ' +\n 'covers them.',\n confirmationText: 'Delete contact',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n try {\n // The route decides against the stored document, keeps any refusal,\n // and restamps what the Contacts list filters by (AGL-3338).\n const removed = await contactRemove.removeOne(id)\n // `siteLabel` and not \"this site\": at the organization level the line\n // lands in a feed that spans every site, so it has to name the one.\n logActivity(`Removed contact from ${siteLabel}`, {\n type: 'contact',\n id,\n name: record?.name || record?.email,\n })\n enqueueSnackbar(\n removed === 'deleted'\n ? 'Contact deleted'\n : `Contact removed from ${siteLabel}`,\n { variant: 'success', persist: false },\n )\n router.push(routes.section('contacts'))\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', {\n variant: 'error',\n allowDuplicate: true,\n })\n }\n }, [\n confirm,\n contactRemove,\n enqueueSnackbar,\n id,\n logActivity,\n record,\n router,\n routes,\n row,\n scope,\n siteLabel,\n ])\n\n const overflowItems: RowActionsMenuItem[] = [\n /*\n * Two records for one person become one (AGL-2625). Opened empty, to be\n * searched, with the OTHER record surviving: a reader on the record they\n * want gone says \"merge this into…\". The dialog lets them switch.\n */\n {\n key: 'merge',\n label: 'Merge into…',\n icon: <MdiIcon path={mdiMerge.path} size={0.8} />,\n disabled: !row || !suiteIncluded,\n disabledReason: suiteIncluded ? 'The contact has not loaded' : crmSuiteLockedReason(),\n onClick: () => merge.open(),\n },\n {\n key: 'remove',\n label: 'Delete contact',\n icon: <MdiIcon path={mdiDeleteOutline.path} size={0.8} />,\n destructive: true,\n disabled: !row,\n disabledReason: 'The contact has not loaded',\n onClick: () => void handleRemove(),\n },\n ]\n // The privacy erasure (AGL-2623): its item joins the overflow, its banner\n // sits under the header while a request waits, its dialog renders once.\n const erase = useErasePersonAction({\n // Filed as the site the page acts as — at the organization level the\n // person's primary holder (AGL-2630).\n hostId: siteHostId,\n orgId,\n subject: record ? { kind: 'contact', id, email: record.email } : null,\n requestedAtMs: Aglyn.readErasureRequestedAtMs(row),\n })\n\n if (notFound) {\n return (\n <CrmRecordHeader\n kind=\"Contact\"\n title={undefined}\n help={contactDocsHelp}\n backHref={routes.section('contacts')}\n backLabel=\"Back to contacts\"\n >\n {/*\n * Not \"no data\". A contact that cannot be read is a different\n * situation from one with an empty profile — it may have been\n * removed from this site, or be held only by a site this reader\n * cannot see.\n */}\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'This contact could not be loaded. It may have been removed from this site.'}\n </Typography>\n </CrmRecordHeader>\n )\n }\n\n return (\n // A create opened from this page — a task, an activity, a deal — files\n // under the site that captured this person unless the reader says\n // otherwise (AGL-2630).\n <CrmCreateSiteDefault hostId={siteHostId}>\n <Stack spacing={3}>\n <CrmRecordHeader\n kind=\"Contact\"\n title={record ? record.name || record.email : undefined}\n // The identity, and the other addresses a merge folded into it.\n subtitle={\n record\n ? [record.email, ...record.alternateEmails.map((email) => `also ${email}`)].join(' · ')\n : undefined\n }\n help={contactDocsHelp}\n backHref={routes.section('contacts')}\n backLabel=\"Back to contacts\"\n actions={\n record ? (\n <>\n <AddToListButton hostId={hostId} org={org} contactId={id} email={record.email} />\n {/* Dial the number, and log the call it was (AGL-2661). */}\n <CrmCallButton\n hostId={siteHostId}\n org={org}\n link={{ contactId: id }}\n phone={record.phone}\n suiteLocked={!suiteIncluded}\n />\n <CrmSendEmailButton\n hostId={siteHostId}\n org={org}\n contactId={id}\n email={record.email}\n name={record.name}\n emailState={record.emailState}\n suiteLocked={!suiteIncluded}\n />\n </>\n ) : null\n }\n menuItems={[...overflowItems, ...erase.menuItems]}\n // The booking door (AGL-2660): this site's services, or at the\n // organization level the capturing site's.\n booking={record ? { hostId: siteHostId, org, kind: 'contact', recordId: id } : undefined}\n loading={!record}\n chips={\n record ? (\n <>\n {/* The verdict on the address (AGL-3245), first: a bounce or a\n do-not-contact mark is what a person deciding to write must see. */}\n <CrmEmailStateChip state={record.emailState} />\n {/* Seen here only through a share (AGL-3336): who shared it. */}\n <CrmShareChipView chip={crmShareChipFor(row, viewingGroup?.hostIds ?? [])} org={org} />\n {/* The mail gateway in front of the address (AGL-3328). */}\n <CrmEmailGatewayChip\n hostId={siteHostId}\n email={record.email}\n emailState={record.emailState}\n enabled={suiteIncluded}\n />\n <CrmRecordChip\n label=\"Stage\"\n value={\n record.lifecycleStage\n ? CONTACT_LIFECYCLE_STAGE_LABELS[record.lifecycleStage]\n : undefined\n }\n />\n <CrmRecordChip\n label=\"Owner\"\n value={record.ownerUid ? members.memberName(record.ownerUid) : undefined}\n />\n {/*\n The last time they opened or clicked one of this site's\n campaigns (AGL-2616) — the relationship's pulse, beside the\n owner who keeps it.\n */}\n <CrmRecordChip\n label=\"Last engaged\"\n value={\n record.lastEmailEngagementAtMs ? (\n <Tooltip title={new Date(record.lastEmailEngagementAtMs).toLocaleString()}>\n <span>\n {Aglyn.activityTimeLabel(record.lastEmailEngagementAtMs, Date.now())}\n </span>\n </Tooltip>\n ) : undefined\n }\n />\n <CrmRecordChip\n label=\"Orders\"\n value={\n record.ordersCount > 0 ? (\n ordersHref ? (\n <AppLink href={ordersHref} color=\"inherit\" underline=\"hover\">\n {`${record.ordersCount.toLocaleString()} · $${(record.ltvCents / 100).toFixed(2)} lifetime`}\n </AppLink>\n ) : (\n `${record.ordersCount.toLocaleString()} · $${(record.ltvCents / 100).toFixed(2)} lifetime`\n )\n ) : undefined\n }\n />\n </>\n ) : null\n }\n >\n {erase.banner}\n </CrmRecordHeader>\n {record && row && scope ? (\n <>\n {suiteIncluded ? null : (\n <CrmSuiteNotice>\n {'Working this contact — the owner, stage and company, tasks, ' +\n 'deals, files and custom fields, logging a call or an activity, ' +\n 'one-to-one email and merging duplicates — is part of the CRM.'}\n </CrmSuiteNotice>\n )}\n {/*\n The cross-site fact, at the organization level only (AGL-2630):\n which sites know this person, and their consent for each. Under\n a site the page IS one site's view and the card would say so on\n every record.\n */}\n {/* What an assistant proposes about the person (AGL-2917), where the plan works a contact. */}\n {suiteIncluded ? (\n <CrmRecordInsightsZone\n hostId={hostId}\n org={org as Record<string, unknown> | undefined}\n kind=\"contact\"\n recordId={id}\n name={record.name || record.email}\n taskLink={{ contactId: id }}\n />\n ) : null}\n {mount ? <ContactKnownByCard row={row} contactId={id} org={org} /> : null}\n {/* Where the person is visible and why, and a manager's share (AGL-3336). */}\n <RecordSharingCard\n object=\"contacts\"\n id={id}\n record={row}\n hostId={hostId ?? null}\n orgId={orgId}\n org={org}\n viewingHostIds={viewingGroup?.hostIds}\n />\n <ContactPropertiesCard\n hostId={hostId}\n org={org}\n record={record}\n orgId={orgId}\n consentGroup={consentGroup}\n seed={{ status, fromCache }}\n members={members}\n suiteLocked={!suiteIncluded}\n />\n <ContactAssociationsCard\n hostId={siteHostId}\n record={record}\n row={row}\n consentGroup={consentGroup}\n seed={{ status, fromCache }}\n basePath={basePath}\n bookingsHref={bookingsHref}\n />\n <ContactTimelineCard\n hostId={hostId}\n org={org}\n contactId={id}\n contact={row}\n campaignHref={campaignHref}\n suiteLocked={!suiteIncluded}\n />\n {/*\n On a plan without the CRM, the cards that only work a record —\n deals, tasks, files — are named in the notice above rather than\n drawn as upsells in a row.\n */}\n {suiteIncluded ? (\n <>\n <ContactDealsCard\n hostId={hostId}\n org={org}\n basePath={basePath}\n contactId={id}\n contactName={record.name || record.email}\n />\n <RecordTasksCard hostId={hostId} org={org} basePath={basePath} contactId={id} />\n {/*\n The files are the VIEWING holder's, like everything else on this\n page: the write is a facet path, so a contract one brand filed\n never appears on a sibling brand's copy of the person.\n */}\n <RecordFilesCard\n scope={scope}\n collection=\"contacts\"\n recordId={id}\n hostId={hostId}\n facetGroupId={consentGroup.groupId}\n mediaIds={record.mediaIds}\n topic=\"contactRecord\"\n />\n </>\n ) : null}\n <ContactDuplicatesCard\n current={{ id, doc: row }}\n scope={scope}\n consentGroup={consentGroup}\n visibleTo={visibleTo}\n basePath={basePath}\n onMerge={(candidate) => merge.open({ other: candidate, keep: 'current' })}\n suiteLocked={!suiteIncluded}\n />\n <ContactMergeDialog\n {...merge.state}\n onClose={merge.close}\n hostId={siteHostId}\n current={{ id, doc: row }}\n scope={scope}\n consentGroup={consentGroup}\n visibleTo={visibleTo}\n memberName={members.memberName}\n // The page's own record was folded away: its address now opens\n // the survivor, which is where the reader is taken.\n onMerged={(survivorId) => {\n if (survivorId !== id) router.replace(routes.contact(survivorId))\n }}\n />\n </>\n ) : null}\n {erase.dialog}\n </Stack>\n </CrmCreateSiteDefault>\n )\n}\nContactDetailPage.displayName = 'ContactDetailPage'\n\nexport default ContactDetailPage\n"],"names":["Aglyn","CONTACT_LIFECYCLE_STAGE_LABELS","pluginDocsHelp","pluginRecordByEmailHref","pluginRecordHref","mdiDeleteOutline","mdiMerge","AppLink","MdiIcon","useConfirmationContext","useSnackbar","useFirestore","useFirestoreDoc","Stack","Tooltip","Typography","doc","useParams","useRouter","useCallback","useMemo","useContactRemove","useCrmActivityLogger","CrmCreateSiteDefault","useCrmOrgMount","crmShareChipFor","useCrmScope","contactPrimaryGroup","contactRecordFromDoc","crmRoutes","ContactAssociationsCard","ContactDuplicatesCard","ContactKnownByCard","ContactMergeDialog","useContactMergeDialog","ContactPropertiesCard","ContactTimelineCard","AddToListButton","useBookingDoor","CrmSendEmailButton","ContactDealsCard","CrmCallButton","CrmEmailStateChip","CrmEmailGatewayChip","CrmRecordChip","CrmRecordHeader","CrmRecordInsightsZone","CrmSuiteNotice","crmSuiteIncluded","crmSuiteLockedReason","useErasePersonAction","RecordFilesCard","CrmShareChipView","RecordSharingCard","RecordTasksCard","useSiteRouteContext","useOrgMembers","contactDocsHelp","anchor","excerpt","ContactDetailPage","props","hostId","org","id","basePath","routes","router","firestore","enqueueSnackbar","confirm","scope","orgId","consentGroup","viewingGroup","visibleTo","mount","merge","suiteIncluded","data","row","status","fromCache","idField","siteHostId","logActivity","record","notFound","Boolean","siteLabel","siteName","members","enabled","params","ordersHref","orgSlug","host","email","String","bookingDoor","siteSlug","siteSubdomain","bookingsHref","open","siteRoute","campaignHref","campaignId","contactRemove","handleRemove","confirmed","title","description","confirmationText","confirmationButtonProps","color","then","catch","removed","removeOne","type","name","variant","persist","push","section","error","console","allowDuplicate","overflowItems","key","label","icon","path","size","disabled","disabledReason","onClick","destructive","erase","subject","kind","requestedAtMs","readErasureRequestedAtMs","undefined","help","backHref","backLabel","spacing","subtitle","alternateEmails","map","join","actions","contactId","link","phone","suiteLocked","emailState","menuItems","booking","recordId","loading","chips","state","chip","hostIds","value","lifecycleStage","ownerUid","memberName","lastEmailEngagementAtMs","Date","toLocaleString","span","activityTimeLabel","now","ordersCount","href","underline","ltvCents","toFixed","banner","taskLink","object","viewingHostIds","seed","contact","contactName","collection","facetGroupId","groupId","mediaIds","topic","current","onMerge","candidate","other","keep","onClose","close","onMerged","survivorId","replace","dialog","displayName"],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import type { GridColDef } from '@mui/x-data-grid';
2
+ import type { CrmShareChip } from '../model/crm-sharing';
2
3
  /**
3
4
  * The filterable fields that get a column. The rest of
4
5
  * `CONTACT_LIST_FILTER_FIELDS` still reaches the filter panel, hidden.
@@ -30,6 +31,8 @@ export interface ContactListColumnOptions {
30
31
  * is known by the site and the column would say so on every line.
31
32
  */
32
33
  siteName?: (hostId: string) => string;
34
+ /** The words of a row's "Shared by…" chip (AGL-3336); absent draws none. */
35
+ shareChipLabel?: (chip: CrmShareChip) => string;
33
36
  /**
34
37
  * One clock for every row of one paint, so two contacts engaged a second
35
38
  * apart cannot read "just now" and "1 min ago". Defaults to the paint's
@@ -63,7 +63,7 @@ import { nextActivityColumn } from "./crm-next-activity-column.js";
63
63
  * (`contactQueryClause`, AGL-3321).
64
64
  */ export function contactListColumns(options) {
65
65
  var _options_nowMs;
66
- const { memberName, siteName } = options;
66
+ const { memberName, siteName, shareChipLabel } = options;
67
67
  const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
68
68
  return [
69
69
  _extends({
@@ -80,12 +80,28 @@ import { nextActivityColumn } from "./crm-next-activity-column.js";
80
80
  lineHeight: 1.25
81
81
  },
82
82
  children: [
83
- /*#__PURE__*/ _jsx(Typography, {
84
- variant: "body2",
83
+ /*#__PURE__*/ _jsxs(Stack, {
84
+ direction: "row",
85
+ spacing: 1,
85
86
  sx: {
86
- lineHeight: 1.25
87
+ alignItems: 'center',
88
+ minWidth: 0
87
89
  },
88
- children: row.name || row.email
90
+ children: [
91
+ /*#__PURE__*/ _jsx(Typography, {
92
+ variant: "body2",
93
+ sx: {
94
+ lineHeight: 1.25
95
+ },
96
+ children: row.name || row.email
97
+ }),
98
+ row.shareChip && shareChipLabel ? /*#__PURE__*/ _jsx(Chip, {
99
+ size: "small",
100
+ variant: "outlined",
101
+ color: "info",
102
+ label: shareChipLabel(row.shareChip)
103
+ }) : null
104
+ ]
89
105
  }),
90
106
  row.name ? /*#__PURE__*/ _jsx(Typography, {
91
107
  variant: "caption",