@aglyn/plugins-crm 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/activity-link-fields.d.ts +34 -0
  3. package/src/lib/components/activity-link-fields.js +141 -0
  4. package/src/lib/components/activity-link-fields.js.map +1 -0
  5. package/src/lib/components/activity-list.d.ts +8 -0
  6. package/src/lib/components/activity-list.js +4 -2
  7. package/src/lib/components/activity-list.js.map +1 -1
  8. package/src/lib/components/activity-queries.d.ts +7 -2
  9. package/src/lib/components/activity-queries.js +7 -3
  10. package/src/lib/components/activity-queries.js.map +1 -1
  11. package/src/lib/components/company-contact-roles-card.d.ts +42 -0
  12. package/src/lib/components/company-contact-roles-card.js +137 -0
  13. package/src/lib/components/company-contact-roles-card.js.map +1 -0
  14. package/src/lib/components/company-detail-page.js +12 -1
  15. package/src/lib/components/company-detail-page.js.map +1 -1
  16. package/src/lib/components/company-edit-drawer.js +9 -6
  17. package/src/lib/components/company-edit-drawer.js.map +1 -1
  18. package/src/lib/components/company-picker.js +4 -2
  19. package/src/lib/components/company-picker.js.map +1 -1
  20. package/src/lib/components/contact-associations-card.js +15 -1
  21. package/src/lib/components/contact-associations-card.js.map +1 -1
  22. package/src/lib/components/contact-detail-page.js +13 -14
  23. package/src/lib/components/contact-detail-page.js.map +1 -1
  24. package/src/lib/components/contact-known-by-card.d.ts +2 -1
  25. package/src/lib/components/contact-known-by-card.js +16 -3
  26. package/src/lib/components/contact-known-by-card.js.map +1 -1
  27. package/src/lib/components/contact-list-columns.js +5 -4
  28. package/src/lib/components/contact-list-columns.js.map +1 -1
  29. package/src/lib/components/contact-properties-card.js +41 -20
  30. package/src/lib/components/contact-properties-card.js.map +1 -1
  31. package/src/lib/components/contact-roles-editor.d.ts +54 -0
  32. package/src/lib/components/contact-roles-editor.js +270 -0
  33. package/src/lib/components/contact-roles-editor.js.map +1 -0
  34. package/src/lib/components/contacts-section.js +6 -4
  35. package/src/lib/components/contacts-section.js.map +1 -1
  36. package/src/lib/components/crm-console-sections.js +1 -1
  37. package/src/lib/components/crm-console-sections.js.map +1 -1
  38. package/src/lib/components/crm-record-picker.d.ts +19 -0
  39. package/src/lib/components/crm-record-picker.js +40 -11
  40. package/src/lib/components/crm-record-picker.js.map +1 -1
  41. package/src/lib/components/crm-related-to-picker.d.ts +33 -0
  42. package/src/lib/components/crm-related-to-picker.js +135 -0
  43. package/src/lib/components/crm-related-to-picker.js.map +1 -0
  44. package/src/lib/components/crm-site-picker.d.ts +13 -1
  45. package/src/lib/components/crm-site-picker.js +15 -6
  46. package/src/lib/components/crm-site-picker.js.map +1 -1
  47. package/src/lib/components/crm-transfer-buttons.d.ts +5 -0
  48. package/src/lib/components/crm-transfer-buttons.js +9 -6
  49. package/src/lib/components/crm-transfer-buttons.js.map +1 -1
  50. package/src/lib/components/deal-contact-roles-card.js +21 -239
  51. package/src/lib/components/deal-contact-roles-card.js.map +1 -1
  52. package/src/lib/components/deal-detail-page.js +2 -1
  53. package/src/lib/components/deal-detail-page.js.map +1 -1
  54. package/src/lib/components/deal-edit-drawer.js +11 -7
  55. package/src/lib/components/deal-edit-drawer.js.map +1 -1
  56. package/src/lib/components/deals-section.js +5 -4
  57. package/src/lib/components/deals-section.js.map +1 -1
  58. package/src/lib/components/log-activity-dialog.d.ts +27 -9
  59. package/src/lib/components/log-activity-dialog.js +96 -32
  60. package/src/lib/components/log-activity-dialog.js.map +1 -1
  61. package/src/lib/components/new-contact-drawer.d.ts +7 -3
  62. package/src/lib/components/new-contact-drawer.js +27 -15
  63. package/src/lib/components/new-contact-drawer.js.map +1 -1
  64. package/src/lib/components/pipeline-stages-dialog.js +5 -1
  65. package/src/lib/components/pipeline-stages-dialog.js.map +1 -1
  66. package/src/lib/components/record-activity-card.d.ts +11 -2
  67. package/src/lib/components/record-activity-card.js +90 -5
  68. package/src/lib/components/record-activity-card.js.map +1 -1
  69. package/src/lib/components/settings-section.d.ts +4 -2
  70. package/src/lib/components/settings-section.js +7 -5
  71. package/src/lib/components/settings-section.js.map +1 -1
  72. package/src/lib/hooks/crm-activity-client-reads.d.ts +8 -0
  73. package/src/lib/hooks/crm-activity-client-reads.js +50 -0
  74. package/src/lib/hooks/crm-activity-client-reads.js.map +1 -0
  75. package/src/lib/hooks/use-campaign-filing-log.d.ts +2 -1
  76. package/src/lib/hooks/use-campaign-filing-log.js +10 -3
  77. package/src/lib/hooks/use-campaign-filing-log.js.map +1 -1
  78. package/src/lib/hooks/use-crm-org-mount.d.ts +8 -6
  79. package/src/lib/hooks/use-crm-org-mount.js +2 -1
  80. package/src/lib/hooks/use-crm-org-mount.js.map +1 -1
  81. package/src/lib/hooks/use-crm-record-names.d.ts +3 -2
  82. package/src/lib/hooks/use-crm-record-names.js +4 -4
  83. package/src/lib/hooks/use-crm-record-names.js.map +1 -1
  84. package/src/lib/hooks/use-crm-scope.d.ts +17 -5
  85. package/src/lib/hooks/use-crm-scope.js +12 -5
  86. package/src/lib/hooks/use-crm-scope.js.map +1 -1
  87. package/src/lib/model/contact-holder.d.ts +12 -0
  88. package/src/lib/model/contact-holder.js +20 -1
  89. package/src/lib/model/contact-holder.js.map +1 -1
  90. package/src/lib/model/contact-record.d.ts +5 -0
  91. package/src/lib/model/contact-record.js +7 -1
  92. package/src/lib/model/contact-record.js.map +1 -1
  93. package/src/lib/model/contact-update.d.ts +6 -0
  94. package/src/lib/model/contact-update.js +1 -0
  95. package/src/lib/model/contact-update.js.map +1 -1
  96. package/src/lib/model/crm-activity-link.d.ts +117 -0
  97. package/src/lib/model/crm-activity-link.js +113 -0
  98. package/src/lib/model/crm-activity-link.js.map +1 -0
  99. package/src/lib/model/crm-api.d.ts +4 -1
  100. package/src/lib/model/crm-api.js +9 -5
  101. package/src/lib/model/crm-api.js.map +1 -1
  102. package/src/lib/model/deal-board-model.js +5 -3
  103. package/src/lib/model/deal-board-model.js.map +1 -1
  104. package/src/lib/model/deal-contact-roles.d.ts +25 -1
  105. package/src/lib/model/deal-contact-roles.js +30 -4
  106. package/src/lib/model/deal-contact-roles.js.map +1 -1
  107. package/src/lib/model/native-vocabulary.d.ts +9 -0
  108. package/src/lib/model/native-vocabulary.js +13 -0
  109. package/src/lib/model/native-vocabulary.js.map +1 -1
  110. package/src/lib/model/settle-stage-names.d.ts +25 -0
  111. package/src/lib/model/settle-stage-names.js +29 -0
  112. package/src/lib/model/settle-stage-names.js.map +1 -0
  113. package/src/lib/native-nav.js +1 -1
  114. package/src/lib/native-nav.js.map +1 -1
  115. package/src/lib/server/add-contact-by-hand.d.ts +84 -0
  116. package/src/lib/server/add-contact-by-hand.js +212 -0
  117. package/src/lib/server/add-contact-by-hand.js.map +1 -0
  118. package/src/lib/server/api-v1/contacts.js +61 -23
  119. package/src/lib/server/api-v1/contacts.js.map +1 -1
  120. package/src/lib/server/api-v1/crm-activities.js +13 -3
  121. package/src/lib/server/api-v1/crm-activities.js.map +1 -1
  122. package/src/lib/server/api-v1/crm-pipelines.d.ts +1 -1
  123. package/src/lib/server/api-v1/crm-pipelines.js +8 -2
  124. package/src/lib/server/api-v1/crm-pipelines.js.map +1 -1
  125. package/src/lib/server/api-v1/crm-shared.d.ts +16 -9
  126. package/src/lib/server/api-v1/crm-shared.js +23 -11
  127. package/src/lib/server/api-v1/crm-shared.js.map +1 -1
  128. package/src/lib/server/api-v1/openapi.js +3 -3
  129. package/src/lib/server/api-v1/openapi.js.map +1 -1
  130. package/src/lib/server/automation-steps.js +11 -9
  131. package/src/lib/server/automation-steps.js.map +1 -1
  132. package/src/lib/server/campaign-filing-activity.js +7 -2
  133. package/src/lib/server/campaign-filing-activity.js.map +1 -1
  134. package/src/lib/server/contact-update.d.ts +4 -0
  135. package/src/lib/server/contact-update.js +71 -10
  136. package/src/lib/server/contact-update.js.map +1 -1
  137. package/src/lib/server/convert-host-lead.js +13 -2
  138. package/src/lib/server/convert-host-lead.js.map +1 -1
  139. package/src/lib/server/convert-open-lead.js +8 -1
  140. package/src/lib/server/convert-open-lead.js.map +1 -1
  141. package/src/lib/server/crm-activity-company.d.ts +25 -0
  142. package/src/lib/server/crm-activity-company.js +34 -0
  143. package/src/lib/server/crm-activity-company.js.map +1 -0
  144. package/src/lib/server/crm-inbound-email.js +21 -15
  145. package/src/lib/server/crm-inbound-email.js.map +1 -1
  146. package/src/lib/server/crm-sharing.js +9 -2
  147. package/src/lib/server/crm-sharing.js.map +1 -1
  148. package/src/lib/server/deal-contact-roles.d.ts +2 -0
  149. package/src/lib/server/deal-contact-roles.js +38 -1
  150. package/src/lib/server/deal-contact-roles.js.map +1 -1
  151. package/src/lib/server/email-send.js +10 -8
  152. package/src/lib/server/email-send.js.map +1 -1
  153. package/src/lib/server/merge-contacts.js +6 -2
  154. package/src/lib/server/merge-contacts.js.map +1 -1
  155. package/src/lib/server/org-caller.d.ts +8 -0
  156. package/src/lib/server/org-caller.js +10 -0
  157. package/src/lib/server/org-caller.js.map +1 -1
  158. package/src/lib/server/record-timeline.d.ts +3 -1
  159. package/src/lib/server/record-timeline.js +11 -3
  160. package/src/lib/server/record-timeline.js.map +1 -1
  161. package/src/lib/server.js +152 -43
  162. package/src/lib/server.js.map +1 -1
  163. package/src/lib/transfer/contacts.js +83 -48
  164. package/src/lib/transfer/contacts.js.map +1 -1
  165. package/src/lib/transfer/fields.js +1 -2
  166. package/src/lib/transfer/fields.js.map +1 -1
@@ -51,7 +51,8 @@ ConsentChip.displayName = 'ConsentChip';
51
51
  * the person's marketing consent FOR EACH of those sites — one verdict per
52
52
  * capturing site, each naming the controller it was read for, because a
53
53
  * basis is per (person, controller) and "consented" with nobody attached
54
- * would be a different and false claim.
54
+ * would be a different and false claim. A person no site captured shows the
55
+ * same verdicts, one per site their consent is recorded on.
55
56
  *
56
57
  * Notes, tags, activity and commercial figures are each holder's own
57
58
  * records and are not projected here: `orgContactRow` is an allow-list for
@@ -128,11 +129,23 @@ ConsentChip.displayName = 'ConsentChip';
128
129
  color: "text.secondary",
129
130
  children: 'No site recorded'
130
131
  }),
131
- /*#__PURE__*/ _jsx(Typography, {
132
+ !capturedByHostIds.length && consent.length ? /*#__PURE__*/ _jsx(Stack, {
133
+ useFlexGap: true,
134
+ direction: "row",
135
+ spacing: 1,
136
+ sx: {
137
+ flexWrap: 'wrap'
138
+ },
139
+ children: consent.map((entry)=>/*#__PURE__*/ _jsx(ConsentChip, {
140
+ entry: entry,
141
+ siteName: mount.siteName(entry.hostId)
142
+ }, entry.hostId))
143
+ }) : null,
144
+ capturedByHostIds.length ? /*#__PURE__*/ _jsx(Typography, {
132
145
  variant: "caption",
133
146
  color: "text.secondary",
134
147
  children: 'The profile, notes, tags and timeline on this page are the first ' + "capturing site's. Open the person in another site to see what " + 'it holds.'
135
- })
148
+ }) : null
136
149
  ]
137
150
  })
138
151
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-known-by-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n orgContactConsentLabel,\n orgContactRow,\n pluginDocsHelp,\n type OrgContactHostConsent,\n} from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Chip, Stack, Typography } from '@mui/material'\nimport { useMemo } from 'react'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport type { CrmOrgDoc } from '../hooks/use-crm-scope'\n\nexport interface ContactKnownByCardProps {\n /** The document as read — the projection below is an allow-list over it. */\n row: Record<string, unknown>\n contactId: string\n /** The org document, the only input that can resolve a declared consent group. */\n org: CrmOrgDoc\n}\n\n/**\n * One consent verdict, rendered.\n *\n * The label always names the controller — `orgContactConsentLabel` is what\n * guarantees that — and the color follows the basis rather than replacing\n * it: a green chip with no name on it would be a bare \"consented\", which is\n * the claim the per-brand model exists to stop anybody making.\n */\nfunction ConsentChip(props: { entry: OrgContactHostConsent; siteName: string }) {\n const { entry, siteName } = props\n return (\n <Chip\n size=\"small\"\n color={\n entry.basis === 'granted'\n ? 'success'\n : entry.basis === 'declined'\n ? 'error'\n : 'default'\n }\n variant={entry.basis === 'unrecorded' ? 'outlined' : 'filled'}\n label={orgContactConsentLabel(entry, siteName)}\n />\n )\n}\nConsentChip.displayName = 'ConsentChip'\n\n/**\n * KNOWN BY — the cross-site fact, on the organization-level record\n * (AGL-2630).\n *\n * A contact document is shared by every site that has captured the person:\n * one human who touched two of an org's sites is ONE row, which is the\n * dedupe the shared address book exists for and what makes the billing unit\n * — unique people per org — mean anything. Under a site that fact is\n * invisible on purpose: a site's hub is one holder's view. This card is the\n * one place it is shown, and it shows exactly what the old org-level\n * address book showed and nothing more: which sites know the person, and\n * the person's marketing consent FOR EACH of those sites — one verdict per\n * capturing site, each naming the controller it was read for, because a\n * basis is per (person, controller) and \"consented\" with nobody attached\n * would be a different and false claim.\n *\n * Notes, tags, activity and commercial figures are each holder's own\n * records and are not projected here: `orgContactRow` is an allow-list for\n * that reason, and the card renders nothing it did not return. The rest of\n * the record page reads through the person's PRIMARY holder; this card is\n * how a reader learns which other sites hold a profile, and the link on\n * each site is how they open it there.\n *\n * Rendered only beneath the org mount — under a site the hook answers\n * `null` and so does the card.\n */\nexport function ContactKnownByCard(props: ContactKnownByCardProps) {\n const { row, contactId, org } = props\n const mount = useCrmOrgMount()\n const projected = useMemo(\n () => orgContactRow(row, contactId, (org ?? null) as Record<string, unknown> | null),\n [row, contactId, org],\n )\n if (!mount) return null\n const { capturedByHostIds, consent } = projected\n return (\n <CardDisplay\n header={'Known by'}\n help={pluginDocsHelp('contacts', { anchor: '#known-by' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {capturedByHostIds.length ? (\n <Stack spacing={1.5}>\n {capturedByHostIds.map((hostId) => {\n const entry = consent.find((verdict) => verdict.hostId === hostId)\n const href = mount.siteHubHref(hostId)\n const name = mount.siteName(hostId)\n return (\n <Stack\n useFlexGap\n key={hostId}\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center', flexWrap: 'wrap' }}\n >\n {/*\n A site whose subdomain has not resolved must still be\n NAMED — the relationship is real, and hiding it would\n understate who knows this person — but not linked,\n because the route would not exist; a plain span, not an\n anchor to nowhere. Two elements are safe here: the card\n mounts only once the record's listener has answered, so\n there is no server render for a client render to disagree\n with.\n */}\n {href ? (\n <AppLink\n href={`${href}/contacts/${encodeURIComponent(contactId)}`}\n underline=\"hover\"\n >\n {name}\n </AppLink>\n ) : (\n <Typography component=\"span\" variant=\"body2\">\n {name}\n </Typography>\n )}\n {entry ? <ConsentChip entry={entry} siteName={name} /> : null}\n {entry?.declared ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Consent given to ${entry.groupName ?? entry.groupId}, which ${name} is part of.`}\n </Typography>\n ) : null}\n </Stack>\n )\n })}\n </Stack>\n ) : (\n /*\n * Unattributed, not unknown. A row written before the attribution\n * existed names no site, and reading that as \"every site\" would\n * put a person in front of businesses that never met them.\n */\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No site recorded'}\n </Typography>\n )}\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'The profile, notes, tags and timeline on this page are the first ' +\n \"capturing site's. Open the person in another site to see what \" +\n 'it holds.'}\n </Typography>\n </Stack>\n </CardDisplay>\n )\n}\nContactKnownByCard.displayName = 'ContactKnownByCard'\n\nexport default ContactKnownByCard\n"],"names":["orgContactConsentLabel","orgContactRow","pluginDocsHelp","AppLink","CardDisplay","Chip","Stack","Typography","useMemo","useCrmOrgMount","ConsentChip","props","entry","siteName","size","color","basis","variant","label","displayName","ContactKnownByCard","row","contactId","org","mount","projected","capturedByHostIds","consent","header","help","anchor","contentGutterX","contentGutterY","spacing","length","map","hostId","find","verdict","href","siteHubHref","name","useFlexGap","direction","sx","alignItems","flexWrap","encodeURIComponent","underline","component","declared","groupName","groupId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SACEA,sBAAsB,EACtBC,aAAa,EACbC,cAAc,QAET,eAAc;AACrB,SAASC,OAAO,EAAEC,WAAW,QAAQ,uBAAsB;AAC3D,SAASC,IAAI,EAAEC,KAAK,EAAEC,UAAU,QAAQ,gBAAe;AACvD,SAASC,OAAO,QAAQ,QAAO;AAC/B,SAASC,cAAc,QAAQ,gCAA4B;AAW3D;;;;;;;CAOC,GACD,SAASC,YAAYC,KAAyD;IAC5E,MAAM,EAAEC,KAAK,EAAEC,QAAQ,EAAE,GAAGF;IAC5B,qBACE,KAACN;QACCS,MAAK;QACLC,OACEH,MAAMI,KAAK,KAAK,YACZ,YACAJ,MAAMI,KAAK,KAAK,aACd,UACA;QAERC,SAASL,MAAMI,KAAK,KAAK,eAAe,aAAa;QACrDE,OAAOlB,uBAAuBY,OAAOC;;AAG3C;AACAH,YAAYS,WAAW,GAAG;AAE1B;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,SAASC,mBAAmBT,KAA8B;IAC/D,MAAM,EAAEU,GAAG,EAAEC,SAAS,EAAEC,GAAG,EAAE,GAAGZ;IAChC,MAAMa,QAAQf;IACd,MAAMgB,YAAYjB,QAChB,IAAMP,cAAcoB,KAAKC,WAAYC,cAAAA,MAAO,OAC5C;QAACF;QAAKC;QAAWC;KAAI;IAEvB,IAAI,CAACC,OAAO,OAAO;IACnB,MAAM,EAAEE,iBAAiB,EAAEC,OAAO,EAAE,GAAGF;IACvC,qBACE,KAACrB;QACCwB,QAAQ;QACRC,MAAM3B,eAAe,YAAY;YAAE4B,QAAQ;QAAY;QACvDC,cAAc;QACdC,cAAc;kBAEd,cAAA,MAAC1B;YAAM2B,SAAS;;gBACbP,kBAAkBQ,MAAM,iBACvB,KAAC5B;oBAAM2B,SAAS;8BACbP,kBAAkBS,GAAG,CAAC,CAACC;4BAqCOxB;wBApC7B,MAAMA,QAAQe,QAAQU,IAAI,CAAC,CAACC,UAAYA,QAAQF,MAAM,KAAKA;wBAC3D,MAAMG,OAAOf,MAAMgB,WAAW,CAACJ;wBAC/B,MAAMK,OAAOjB,MAAMX,QAAQ,CAACuB;wBAC5B,qBACE,MAAC9B;4BACCoC,UAAU;4BAEVC,WAAU;4BACVV,SAAS;4BACTW,IAAI;gCAAEC,YAAY;gCAAUC,UAAU;4BAAO;;gCAY5CP,qBACC,KAACpC;oCACCoC,MAAM,GAAGA,KAAK,UAAU,EAAEQ,mBAAmBzB,YAAY;oCACzD0B,WAAU;8CAETP;mDAGH,KAAClC;oCAAW0C,WAAU;oCAAOhC,SAAQ;8CAClCwB;;gCAGJ7B,sBAAQ,KAACF;oCAAYE,OAAOA;oCAAOC,UAAU4B;qCAAW;gCACxD7B,CAAAA,yBAAAA,MAAOsC,QAAQ,kBACd,KAAC3C;oCAAWU,SAAQ;oCAAUF,OAAM;8CACjC,CAAC,iBAAiB,GAAEH,mBAAAA,MAAMuC,SAAS,YAAfvC,mBAAmBA,MAAMwC,OAAO,CAAC,QAAQ,EAAEX,KAAK,YAAY,CAAC;qCAElF;;2BAhCCL;oBAmCX;qBAGF;;;;WAIC,iBACD,KAAC7B;oBAAWU,SAAQ;oBAAQF,OAAM;8BAC/B;;8BAGL,KAACR;oBAAWU,SAAQ;oBAAUF,OAAM;8BACjC,sEACC,mEACA;;;;;AAKZ;AACAK,mBAAmBD,WAAW,GAAG;AAEjC,eAAeC,mBAAkB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-known-by-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n orgContactConsentLabel,\n orgContactRow,\n pluginDocsHelp,\n type OrgContactHostConsent,\n} from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport { Chip, Stack, Typography } from '@mui/material'\nimport { useMemo } from 'react'\nimport { useCrmOrgMount } from '../hooks/use-crm-org-mount'\nimport type { CrmOrgDoc } from '../hooks/use-crm-scope'\n\nexport interface ContactKnownByCardProps {\n /** The document as read — the projection below is an allow-list over it. */\n row: Record<string, unknown>\n contactId: string\n /** The org document, the only input that can resolve a declared consent group. */\n org: CrmOrgDoc\n}\n\n/**\n * One consent verdict, rendered.\n *\n * The label always names the controller — `orgContactConsentLabel` is what\n * guarantees that — and the color follows the basis rather than replacing\n * it: a green chip with no name on it would be a bare \"consented\", which is\n * the claim the per-brand model exists to stop anybody making.\n */\nfunction ConsentChip(props: { entry: OrgContactHostConsent; siteName: string }) {\n const { entry, siteName } = props\n return (\n <Chip\n size=\"small\"\n color={\n entry.basis === 'granted'\n ? 'success'\n : entry.basis === 'declined'\n ? 'error'\n : 'default'\n }\n variant={entry.basis === 'unrecorded' ? 'outlined' : 'filled'}\n label={orgContactConsentLabel(entry, siteName)}\n />\n )\n}\nConsentChip.displayName = 'ConsentChip'\n\n/**\n * KNOWN BY — the cross-site fact, on the organization-level record\n * (AGL-2630).\n *\n * A contact document is shared by every site that has captured the person:\n * one human who touched two of an org's sites is ONE row, which is the\n * dedupe the shared address book exists for and what makes the billing unit\n * — unique people per org — mean anything. Under a site that fact is\n * invisible on purpose: a site's hub is one holder's view. This card is the\n * one place it is shown, and it shows exactly what the old org-level\n * address book showed and nothing more: which sites know the person, and\n * the person's marketing consent FOR EACH of those sites — one verdict per\n * capturing site, each naming the controller it was read for, because a\n * basis is per (person, controller) and \"consented\" with nobody attached\n * would be a different and false claim. A person no site captured shows the\n * same verdicts, one per site their consent is recorded on.\n *\n * Notes, tags, activity and commercial figures are each holder's own\n * records and are not projected here: `orgContactRow` is an allow-list for\n * that reason, and the card renders nothing it did not return. The rest of\n * the record page reads through the person's PRIMARY holder; this card is\n * how a reader learns which other sites hold a profile, and the link on\n * each site is how they open it there.\n *\n * Rendered only beneath the org mount — under a site the hook answers\n * `null` and so does the card.\n */\nexport function ContactKnownByCard(props: ContactKnownByCardProps) {\n const { row, contactId, org } = props\n const mount = useCrmOrgMount()\n const projected = useMemo(\n () => orgContactRow(row, contactId, (org ?? null) as Record<string, unknown> | null),\n [row, contactId, org],\n )\n if (!mount) return null\n const { capturedByHostIds, consent } = projected\n return (\n <CardDisplay\n header={'Known by'}\n help={pluginDocsHelp('contacts', { anchor: '#known-by' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n {capturedByHostIds.length ? (\n <Stack spacing={1.5}>\n {capturedByHostIds.map((hostId) => {\n const entry = consent.find((verdict) => verdict.hostId === hostId)\n const href = mount.siteHubHref(hostId)\n const name = mount.siteName(hostId)\n return (\n <Stack\n useFlexGap\n key={hostId}\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center', flexWrap: 'wrap' }}\n >\n {/*\n A site whose subdomain has not resolved must still be\n NAMED — the relationship is real, and hiding it would\n understate who knows this person — but not linked,\n because the route would not exist; a plain span, not an\n anchor to nowhere. Two elements are safe here: the card\n mounts only once the record's listener has answered, so\n there is no server render for a client render to disagree\n with.\n */}\n {href ? (\n <AppLink\n href={`${href}/contacts/${encodeURIComponent(contactId)}`}\n underline=\"hover\"\n >\n {name}\n </AppLink>\n ) : (\n <Typography component=\"span\" variant=\"body2\">\n {name}\n </Typography>\n )}\n {entry ? <ConsentChip entry={entry} siteName={name} /> : null}\n {entry?.declared ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`Consent given to ${entry.groupName ?? entry.groupId}, which ${name} is part of.`}\n </Typography>\n ) : null}\n </Stack>\n )\n })}\n </Stack>\n ) : (\n /*\n * Unattributed, not unknown. A row written before the attribution\n * existed names no site, and reading that as \"every site\" would\n * put a person in front of businesses that never met them.\n */\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No site recorded'}\n </Typography>\n )}\n {!capturedByHostIds.length && consent.length ? (\n <Stack useFlexGap direction=\"row\" spacing={1} sx={{ flexWrap: 'wrap' }}>\n {consent.map((entry) => (\n <ConsentChip\n key={entry.hostId}\n entry={entry}\n siteName={mount.siteName(entry.hostId)}\n />\n ))}\n </Stack>\n ) : null}\n {capturedByHostIds.length ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'The profile, notes, tags and timeline on this page are the first ' +\n \"capturing site's. Open the person in another site to see what \" +\n 'it holds.'}\n </Typography>\n ) : null}\n </Stack>\n </CardDisplay>\n )\n}\nContactKnownByCard.displayName = 'ContactKnownByCard'\n\nexport default ContactKnownByCard\n"],"names":["orgContactConsentLabel","orgContactRow","pluginDocsHelp","AppLink","CardDisplay","Chip","Stack","Typography","useMemo","useCrmOrgMount","ConsentChip","props","entry","siteName","size","color","basis","variant","label","displayName","ContactKnownByCard","row","contactId","org","mount","projected","capturedByHostIds","consent","header","help","anchor","contentGutterX","contentGutterY","spacing","length","map","hostId","find","verdict","href","siteHubHref","name","useFlexGap","direction","sx","alignItems","flexWrap","encodeURIComponent","underline","component","declared","groupName","groupId"],"mappings":"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"}
@@ -21,6 +21,7 @@ import { labelContactSource } from "@aglyn/aglyn/plugin-manager/plugin-contact-c
21
21
  import { hiddenFilterColumns, listFilterColumn } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
22
22
  import { Chip, Stack, Tooltip, Typography } from "@mui/material";
23
23
  import { CONTACT_LIST_FILTER_FIELDS, CONTACT_LIST_FILTER_HEADERS } from "../constants/contact-filters.js";
24
+ import { contactRecordLabel } from "../model/contact-record.js";
24
25
  import { CrmEmailStateChip } from "./crm-email-state-chip.js";
25
26
  import { nextActivityColumn } from "./crm-next-activity-column.js";
26
27
  /**
@@ -73,7 +74,7 @@ import { nextActivityColumn } from "./crm-next-activity-column.js";
73
74
  flex: 1.6,
74
75
  minWidth: 220
75
76
  }, listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'name'), {
76
- valueGetter: (_value, row)=>String(row.name || row.email || ''),
77
+ valueGetter: (_value, row)=>contactRecordLabel(row),
77
78
  renderCell: ({ row })=>/*#__PURE__*/ _jsxs(Stack, {
78
79
  sx: {
79
80
  justifyContent: 'center',
@@ -94,7 +95,7 @@ import { nextActivityColumn } from "./crm-next-activity-column.js";
94
95
  sx: {
95
96
  lineHeight: 1.25
96
97
  },
97
- children: row.name || row.email
98
+ children: contactRecordLabel(row)
98
99
  }),
99
100
  row.shareChip && shareChipLabel ? /*#__PURE__*/ _jsx(Chip, {
100
101
  size: "small",
@@ -104,14 +105,14 @@ import { nextActivityColumn } from "./crm-next-activity-column.js";
104
105
  }) : null
105
106
  ]
106
107
  }),
107
- row.name ? /*#__PURE__*/ _jsx(Typography, {
108
+ row.name && (row.email || row.phone || row.mobilePhone) ? /*#__PURE__*/ _jsx(Typography, {
108
109
  variant: "caption",
109
110
  color: "text.secondary",
110
111
  sx: {
111
112
  lineHeight: 1.25
112
113
  },
113
114
  noWrap: true,
114
- children: row.email
115
+ children: row.email || row.phone || row.mobilePhone
115
116
  }) : null
116
117
  ]
117
118
  })
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-list-columns.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n activityTimeLabel,\n CONTACT_LIFECYCLE_STAGE_LABELS,\n EMAIL_STATE_LABELS,\n} from '@aglyn/aglyn'\nimport { labelContactSource } from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n hiddenFilterColumns,\n listFilterColumn,\n} from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { Chip, Stack, Tooltip, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n CONTACT_LIST_FILTER_FIELDS,\n CONTACT_LIST_FILTER_HEADERS,\n} from '../constants/contact-filters'\nimport type { ContactRecord } from '../model/contact-record'\nimport type { CrmShareChip } from '../model/crm-sharing'\nimport { CrmEmailStateChip } from './crm-email-state-chip'\nimport { nextActivityColumn } from './crm-next-activity-column'\n\n/**\n * The filterable fields that get a column. The rest of\n * `CONTACT_LIST_FILTER_FIELDS` still reaches the filter panel, hidden.\n */\n/**\n * The declared filter fields that ARE table columns, so the hidden\n * filter-only columns are built for the others alone. Owner and Stage\n * joined it when the grammar learned them (AGL-2617): each is a shown\n * column already, and a second, hidden one under the same field would be\n * a duplicate the grid refuses.\n */\nexport const CONTACT_FILTER_COLUMNS = [\n 'name',\n 'ownerUid',\n 'lifecycleStage',\n 'sources',\n 'tags',\n 'updatedAt',\n 'nextTaskAtMs',\n 'emailState',\n]\n\n/**\n * The columns that ship HIDDEN, for the grid's visibility model (AGL-2616).\n *\n * \"Last engaged\" is a fact most lists do not need on screen and every list\n * can turn on from the column menu; shipping it visible would widen a\n * table that already carries seven columns for a figure only a marketing\n * reader scans.\n */\nexport const CONTACT_OPTIONAL_COLUMNS = ['lastEmailEngagementAtMs']\n\nexport interface ContactListColumnOptions {\n /** The owner's name for a uid — the roster's, or the uid itself. */\n memberName: (uid: string) => string\n /**\n * How a site reads, by document id — handed in at the ORGANIZATION level\n * only (AGL-2630), where it turns on the \"Known by\" column: the sites\n * that have captured each person. Absent under a site, where every row\n * is known by the site and the column would say so on every line.\n */\n siteName?: (hostId: string) => string\n /** The words of a row's \"Shared by…\" chip (AGL-3336); absent draws none. */\n shareChipLabel?: (chip: CrmShareChip) => string\n /**\n * One clock for every row of one paint, so two contacts engaged a second\n * apart cannot read \"just now\" and \"1 min ago\". Defaults to the paint's\n * own `Date.now()`.\n */\n nowMs?: number\n}\n\n/**\n * The contacts list's columns (AGL-2596), in their own module so the list\n * file stays about the QUERY and the toolbar while other surfaces — a bulk\n * bar, an import — grow beside it.\n *\n * Owner and Stage read off the flattened row, which is already this\n * holder's facet (`contactRecordFromDoc`), so neither column can show\n * another holder's assignment. Filtering by either is the list's: the panel\n * names the field and the list asks the holder's facet key of its query\n * (`contactQueryClause`, AGL-3321).\n */\nexport function contactListColumns(\n options: ContactListColumnOptions,\n): GridColDef[] {\n const { memberName, siteName, shareChipLabel } = options\n const nowMs = options.nowMs ?? Date.now()\n return [\n {\n field: 'name',\n headerName: 'Contact',\n flex: 1.6,\n minWidth: 220,\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'name'),\n valueGetter: (_value, row: ContactRecord) =>\n String(row.name || row.email || ''),\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Stack sx={{ justifyContent: 'center', height: '100%', lineHeight: 1.25 }}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', minWidth: 0 }}>\n <Typography variant=\"body2\" sx={{ lineHeight: 1.25 }}>\n {row.name || row.email}\n </Typography>\n {/* Seen on this site only through a share (AGL-3336). */}\n {row.shareChip && shareChipLabel ? (\n <Chip size=\"small\" variant=\"outlined\" color=\"info\" label={shareChipLabel(row.shareChip)} />\n ) : null}\n </Stack>\n {row.name ? (\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ lineHeight: 1.25 }}\n noWrap\n >\n {row.email}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n {\n field: 'ownerUid',\n headerName: 'Owner',\n flex: 0.9,\n minWidth: 140,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.ownerUid ? memberName(row.ownerUid) : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.ownerUid ? (\n <Typography variant=\"body2\" noWrap>\n {memberName(row.ownerUid)}\n </Typography>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n {\n field: 'lifecycleStage',\n headerName: 'Stage',\n flex: 0.9,\n minWidth: 150,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.lifecycleStage ? CONTACT_LIFECYCLE_STAGE_LABELS[row.lifecycleStage] : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.lifecycleStage ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={CONTACT_LIFECYCLE_STAGE_LABELS[row.lifecycleStage]}\n />\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n /*\n * KNOWN BY (AGL-2630): the sites that have captured this person, which\n * is the cross-site fact the organization-level list exists to show —\n * one deduplicated row per person, and which of the org's sites know\n * them. `capturedByHostIds` is a top-level array precisely so the\n * question can be answered without opening a facet. Not filterable from\n * the panel: an `array-contains` on it would displace the scope clause\n * under a site, and a search or a facet filter at the org level (one\n * array clause per query, AGL-3321). A row that names no site predates attribution and says so,\n * rather than reading as \"every site\".\n */\n ...(siteName\n ? [\n {\n field: 'capturedByHostIds',\n headerName: 'Known by',\n flex: 1.1,\n minWidth: 200,\n sortable: false,\n filterable: false,\n valueGetter: (_value: unknown, row: ContactRecord) =>\n row.capturedByHostIds.map(siteName).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.capturedByHostIds.length ? (\n <Stack\n direction=\"row\"\n spacing={0.5}\n sx={{ alignItems: 'center', height: '100%', flexWrap: 'nowrap' }}\n >\n {row.capturedByHostIds.slice(0, 2).map((hostId) => (\n <Chip key={hostId} size=\"small\" label={siteName(hostId)} />\n ))}\n {row.capturedByHostIds.length > 2 ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`+${row.capturedByHostIds.length - 2}`}\n />\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No site recorded'}\n </Typography>\n ),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'sources',\n headerName: 'Sources',\n flex: 1,\n minWidth: 160,\n // A map of provenance flags, not a scalar — `sources.form == true` is\n // queryable one key at a time, which is a menu of its own rather than\n // a filter on this column.\n filterable: false,\n sortable: false,\n valueGetter: (_value, row: ContactRecord) =>\n Object.keys(row.sources ?? {}).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Stack\n direction=\"row\"\n spacing={0.5}\n sx={{ alignItems: 'center', height: '100%' }}\n >\n {Object.keys(row.sources ?? {}).map((source) => (\n <Chip\n key={source}\n label={labelContactSource(source)}\n size=\"small\"\n />\n ))}\n </Stack>\n ),\n },\n {\n field: 'tags',\n headerName: 'Tags',\n flex: 1,\n minWidth: 150,\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'tags'),\n sortable: false,\n valueGetter: (_value, row: ContactRecord) => (row.tags ?? []).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) =>\n (row.tags ?? []).slice(0, 3).join(', '),\n },\n {\n field: 'updatedAt',\n headerName: 'Last activity',\n flex: 0.8,\n minWidth: 150,\n // `type: 'date'` is what gives the panel a date PICKER rather than a\n // free-text box for a value the query reads as a day.\n type: 'date',\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'updatedAt'),\n valueGetter: (_value, row: ContactRecord) => {\n const seconds = (row.updatedAt as { seconds?: number } | undefined)?.seconds\n return seconds ? new Date(seconds * 1000) : null\n },\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {row.interactions?.[0]\n ? new Date(row.interactions[0].atMs).toLocaleDateString()\n : '—'}\n </Typography>\n ),\n },\n // When the earliest open task against the person is due (AGL-2661).\n nextActivityColumn(nowMs),\n {\n /*\n * When the person last opened or clicked one of this holder's\n * campaigns (AGL-2616). Off the facet like Owner and Stage, so it can\n * never show another holder's readers; `filterable: false` for the\n * same reason theirs are — a facet path is not a query the panel can\n * make. A date column, so a sort on it orders by the instant and an\n * export gets a date; the cell prints the relative form the timeline\n * uses and carries the full stamp in its tooltip.\n */\n field: 'lastEmailEngagementAtMs',\n headerName: 'Last engaged',\n flex: 0.8,\n minWidth: 140,\n type: 'date',\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.lastEmailEngagementAtMs ? new Date(row.lastEmailEngagementAtMs) : null,\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.lastEmailEngagementAtMs ? (\n <Tooltip title={new Date(row.lastEmailEngagementAtMs).toLocaleString()}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {activityTimeLabel(row.lastEmailEngagementAtMs, nowMs)}\n </Typography>\n </Tooltip>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n {\n /*\n * The verdict on the address (AGL-3245): the same chip the record page\n * carries, so a bounced or blocked person is told apart in the list a\n * member picks recipients from. Sorted and exported by its label;\n * filtered through the bar's `emailState` clause, not the panel.\n */\n field: 'emailState',\n headerName: 'Email',\n flex: 0.8,\n minWidth: 140,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.emailState ? EMAIL_STATE_LABELS[row.emailState.status] : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.emailState ? (\n <CrmEmailStateChip state={row.emailState} />\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n ...hiddenFilterColumns(\n CONTACT_LIST_FILTER_FIELDS,\n CONTACT_FILTER_COLUMNS,\n CONTACT_LIST_FILTER_HEADERS,\n ),\n ]\n}\n"],"names":["activityTimeLabel","CONTACT_LIFECYCLE_STAGE_LABELS","EMAIL_STATE_LABELS","labelContactSource","hiddenFilterColumns","listFilterColumn","Chip","Stack","Tooltip","Typography","CONTACT_LIST_FILTER_FIELDS","CONTACT_LIST_FILTER_HEADERS","CrmEmailStateChip","nextActivityColumn","CONTACT_FILTER_COLUMNS","CONTACT_OPTIONAL_COLUMNS","contactListColumns","options","memberName","siteName","shareChipLabel","nowMs","Date","now","field","headerName","flex","minWidth","valueGetter","_value","row","String","name","email","renderCell","sx","justifyContent","height","lineHeight","direction","spacing","alignItems","variant","shareChip","size","color","label","noWrap","filterable","ownerUid","lifecycleStage","sortable","capturedByHostIds","map","join","length","flexWrap","slice","hostId","Object","keys","sources","source","tags","type","seconds","updatedAt","interactions","atMs","toLocaleDateString","lastEmailEngagementAtMs","title","toLocaleString","emailState","status","state"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-list-columns.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n activityTimeLabel,\n CONTACT_LIFECYCLE_STAGE_LABELS,\n EMAIL_STATE_LABELS,\n} from '@aglyn/aglyn'\nimport { labelContactSource } from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n hiddenFilterColumns,\n listFilterColumn,\n} from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { Chip, Stack, Tooltip, Typography } from '@mui/material'\nimport type { GridColDef } from '@mui/x-data-grid'\nimport {\n CONTACT_LIST_FILTER_FIELDS,\n CONTACT_LIST_FILTER_HEADERS,\n} from '../constants/contact-filters'\nimport { contactRecordLabel, type ContactRecord } from '../model/contact-record'\nimport type { CrmShareChip } from '../model/crm-sharing'\nimport { CrmEmailStateChip } from './crm-email-state-chip'\nimport { nextActivityColumn } from './crm-next-activity-column'\n\n/**\n * The filterable fields that get a column. The rest of\n * `CONTACT_LIST_FILTER_FIELDS` still reaches the filter panel, hidden.\n */\n/**\n * The declared filter fields that ARE table columns, so the hidden\n * filter-only columns are built for the others alone. Owner and Stage\n * joined it when the grammar learned them (AGL-2617): each is a shown\n * column already, and a second, hidden one under the same field would be\n * a duplicate the grid refuses.\n */\nexport const CONTACT_FILTER_COLUMNS = [\n 'name',\n 'ownerUid',\n 'lifecycleStage',\n 'sources',\n 'tags',\n 'updatedAt',\n 'nextTaskAtMs',\n 'emailState',\n]\n\n/**\n * The columns that ship HIDDEN, for the grid's visibility model (AGL-2616).\n *\n * \"Last engaged\" is a fact most lists do not need on screen and every list\n * can turn on from the column menu; shipping it visible would widen a\n * table that already carries seven columns for a figure only a marketing\n * reader scans.\n */\nexport const CONTACT_OPTIONAL_COLUMNS = ['lastEmailEngagementAtMs']\n\nexport interface ContactListColumnOptions {\n /** The owner's name for a uid — the roster's, or the uid itself. */\n memberName: (uid: string) => string\n /**\n * How a site reads, by document id — handed in at the ORGANIZATION level\n * only (AGL-2630), where it turns on the \"Known by\" column: the sites\n * that have captured each person. Absent under a site, where every row\n * is known by the site and the column would say so on every line.\n */\n siteName?: (hostId: string) => string\n /** The words of a row's \"Shared by…\" chip (AGL-3336); absent draws none. */\n shareChipLabel?: (chip: CrmShareChip) => string\n /**\n * One clock for every row of one paint, so two contacts engaged a second\n * apart cannot read \"just now\" and \"1 min ago\". Defaults to the paint's\n * own `Date.now()`.\n */\n nowMs?: number\n}\n\n/**\n * The contacts list's columns (AGL-2596), in their own module so the list\n * file stays about the QUERY and the toolbar while other surfaces — a bulk\n * bar, an import — grow beside it.\n *\n * Owner and Stage read off the flattened row, which is already this\n * holder's facet (`contactRecordFromDoc`), so neither column can show\n * another holder's assignment. Filtering by either is the list's: the panel\n * names the field and the list asks the holder's facet key of its query\n * (`contactQueryClause`, AGL-3321).\n */\nexport function contactListColumns(\n options: ContactListColumnOptions,\n): GridColDef[] {\n const { memberName, siteName, shareChipLabel } = options\n const nowMs = options.nowMs ?? Date.now()\n return [\n {\n field: 'name',\n headerName: 'Contact',\n flex: 1.6,\n minWidth: 220,\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'name'),\n valueGetter: (_value, row: ContactRecord) =>\n contactRecordLabel(row),\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Stack sx={{ justifyContent: 'center', height: '100%', lineHeight: 1.25 }}>\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center', minWidth: 0 }}>\n <Typography variant=\"body2\" sx={{ lineHeight: 1.25 }}>\n {contactRecordLabel(row)}\n </Typography>\n {/* Seen on this site only through a share (AGL-3336). */}\n {row.shareChip && shareChipLabel ? (\n <Chip size=\"small\" variant=\"outlined\" color=\"info\" label={shareChipLabel(row.shareChip)} />\n ) : null}\n </Stack>\n {row.name && (row.email || row.phone || row.mobilePhone) ? (\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ lineHeight: 1.25 }}\n noWrap\n >\n {row.email || row.phone || row.mobilePhone}\n </Typography>\n ) : null}\n </Stack>\n ),\n },\n {\n field: 'ownerUid',\n headerName: 'Owner',\n flex: 0.9,\n minWidth: 140,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.ownerUid ? memberName(row.ownerUid) : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.ownerUid ? (\n <Typography variant=\"body2\" noWrap>\n {memberName(row.ownerUid)}\n </Typography>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n {\n field: 'lifecycleStage',\n headerName: 'Stage',\n flex: 0.9,\n minWidth: 150,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.lifecycleStage ? CONTACT_LIFECYCLE_STAGE_LABELS[row.lifecycleStage] : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.lifecycleStage ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={CONTACT_LIFECYCLE_STAGE_LABELS[row.lifecycleStage]}\n />\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n /*\n * KNOWN BY (AGL-2630): the sites that have captured this person, which\n * is the cross-site fact the organization-level list exists to show —\n * one deduplicated row per person, and which of the org's sites know\n * them. `capturedByHostIds` is a top-level array precisely so the\n * question can be answered without opening a facet. Not filterable from\n * the panel: an `array-contains` on it would displace the scope clause\n * under a site, and a search or a facet filter at the org level (one\n * array clause per query, AGL-3321). A row that names no site predates attribution and says so,\n * rather than reading as \"every site\".\n */\n ...(siteName\n ? [\n {\n field: 'capturedByHostIds',\n headerName: 'Known by',\n flex: 1.1,\n minWidth: 200,\n sortable: false,\n filterable: false,\n valueGetter: (_value: unknown, row: ContactRecord) =>\n row.capturedByHostIds.map(siteName).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.capturedByHostIds.length ? (\n <Stack\n direction=\"row\"\n spacing={0.5}\n sx={{ alignItems: 'center', height: '100%', flexWrap: 'nowrap' }}\n >\n {row.capturedByHostIds.slice(0, 2).map((hostId) => (\n <Chip key={hostId} size=\"small\" label={siteName(hostId)} />\n ))}\n {row.capturedByHostIds.length > 2 ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={`+${row.capturedByHostIds.length - 2}`}\n />\n ) : null}\n </Stack>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'No site recorded'}\n </Typography>\n ),\n } satisfies GridColDef,\n ]\n : []),\n {\n field: 'sources',\n headerName: 'Sources',\n flex: 1,\n minWidth: 160,\n // A map of provenance flags, not a scalar — `sources.form == true` is\n // queryable one key at a time, which is a menu of its own rather than\n // a filter on this column.\n filterable: false,\n sortable: false,\n valueGetter: (_value, row: ContactRecord) =>\n Object.keys(row.sources ?? {}).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Stack\n direction=\"row\"\n spacing={0.5}\n sx={{ alignItems: 'center', height: '100%' }}\n >\n {Object.keys(row.sources ?? {}).map((source) => (\n <Chip\n key={source}\n label={labelContactSource(source)}\n size=\"small\"\n />\n ))}\n </Stack>\n ),\n },\n {\n field: 'tags',\n headerName: 'Tags',\n flex: 1,\n minWidth: 150,\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'tags'),\n sortable: false,\n valueGetter: (_value, row: ContactRecord) => (row.tags ?? []).join(', '),\n renderCell: ({ row }: { row: ContactRecord }) =>\n (row.tags ?? []).slice(0, 3).join(', '),\n },\n {\n field: 'updatedAt',\n headerName: 'Last activity',\n flex: 0.8,\n minWidth: 150,\n // `type: 'date'` is what gives the panel a date PICKER rather than a\n // free-text box for a value the query reads as a day.\n type: 'date',\n ...listFilterColumn(CONTACT_LIST_FILTER_FIELDS, 'updatedAt'),\n valueGetter: (_value, row: ContactRecord) => {\n const seconds = (row.updatedAt as { seconds?: number } | undefined)?.seconds\n return seconds ? new Date(seconds * 1000) : null\n },\n renderCell: ({ row }: { row: ContactRecord }) => (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {row.interactions?.[0]\n ? new Date(row.interactions[0].atMs).toLocaleDateString()\n : '—'}\n </Typography>\n ),\n },\n // When the earliest open task against the person is due (AGL-2661).\n nextActivityColumn(nowMs),\n {\n /*\n * When the person last opened or clicked one of this holder's\n * campaigns (AGL-2616). Off the facet like Owner and Stage, so it can\n * never show another holder's readers; `filterable: false` for the\n * same reason theirs are — a facet path is not a query the panel can\n * make. A date column, so a sort on it orders by the instant and an\n * export gets a date; the cell prints the relative form the timeline\n * uses and carries the full stamp in its tooltip.\n */\n field: 'lastEmailEngagementAtMs',\n headerName: 'Last engaged',\n flex: 0.8,\n minWidth: 140,\n type: 'date',\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.lastEmailEngagementAtMs ? new Date(row.lastEmailEngagementAtMs) : null,\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.lastEmailEngagementAtMs ? (\n <Tooltip title={new Date(row.lastEmailEngagementAtMs).toLocaleString()}>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {activityTimeLabel(row.lastEmailEngagementAtMs, nowMs)}\n </Typography>\n </Tooltip>\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n {\n /*\n * The verdict on the address (AGL-3245): the same chip the record page\n * carries, so a bounced or blocked person is told apart in the list a\n * member picks recipients from. Sorted and exported by its label;\n * filtered through the bar's `emailState` clause, not the panel.\n */\n field: 'emailState',\n headerName: 'Email',\n flex: 0.8,\n minWidth: 140,\n filterable: false,\n valueGetter: (_value, row: ContactRecord) =>\n row.emailState ? EMAIL_STATE_LABELS[row.emailState.status] : '',\n renderCell: ({ row }: { row: ContactRecord }) =>\n row.emailState ? (\n <CrmEmailStateChip state={row.emailState} />\n ) : (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {'—'}\n </Typography>\n ),\n },\n ...hiddenFilterColumns(\n CONTACT_LIST_FILTER_FIELDS,\n CONTACT_FILTER_COLUMNS,\n CONTACT_LIST_FILTER_HEADERS,\n ),\n ]\n}\n"],"names":["activityTimeLabel","CONTACT_LIFECYCLE_STAGE_LABELS","EMAIL_STATE_LABELS","labelContactSource","hiddenFilterColumns","listFilterColumn","Chip","Stack","Tooltip","Typography","CONTACT_LIST_FILTER_FIELDS","CONTACT_LIST_FILTER_HEADERS","contactRecordLabel","CrmEmailStateChip","nextActivityColumn","CONTACT_FILTER_COLUMNS","CONTACT_OPTIONAL_COLUMNS","contactListColumns","options","memberName","siteName","shareChipLabel","nowMs","Date","now","field","headerName","flex","minWidth","valueGetter","_value","row","renderCell","sx","justifyContent","height","lineHeight","direction","spacing","alignItems","variant","shareChip","size","color","label","name","email","phone","mobilePhone","noWrap","filterable","ownerUid","lifecycleStage","sortable","capturedByHostIds","map","join","length","flexWrap","slice","hostId","Object","keys","sources","source","tags","type","seconds","updatedAt","interactions","atMs","toLocaleDateString","lastEmailEngagementAtMs","title","toLocaleString","emailState","status","state"],"mappings":"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"}
@@ -17,7 +17,7 @@
17
17
  import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
19
  import * as Aglyn from "@aglyn/aglyn";
20
- import { composeContactName, CONTACT_BIRTHDATE_REFUSAL, CONTACT_EXTRA_PHONE_FIELDS, CONTACT_LIFECYCLE_STAGE_LABELS, CONTACT_LIFECYCLE_STAGES, CONTACT_NAME_PART_MAX, CONTACT_PHONE_FIELD_LABELS, CRM_SALUTATION_PICKLIST, crmTelHref, normalizeContactBirthdate, normalizePhone } from "@aglyn/aglyn";
20
+ import { composeContactName, CONTACT_BIRTHDATE_REFUSAL, CONTACT_EXTRA_PHONE_FIELDS, CONTACT_LIFECYCLE_STAGE_LABELS, CONTACT_LIFECYCLE_STAGES, CONTACT_NAME_PART_MAX, CONTACT_PHONE_FIELD_LABELS, CRM_SALUTATION_PICKLIST, crmTelHref, normalizeContactBirthdate, normalizeContactEmail, normalizePhone } from "@aglyn/aglyn";
21
21
  import { mdiPhoneOutline } from "@aglyn/shared-data-mdi";
22
22
  import { CardDisplay, MdiIcon } from "@aglyn/shared-ui-jsx";
23
23
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
@@ -190,6 +190,8 @@ export function ContactPropertiesCard(props) {
190
190
  const [lastName, setLastName] = useState((_record_lastName = record.lastName) != null ? _record_lastName : '');
191
191
  const [phones, setPhones] = useState(()=>phoneDraftsFrom(record));
192
192
  const [doNotCall, setDoNotCall] = useState(record.doNotCall === true);
193
+ const [email, setEmail] = useState(record.email);
194
+ const [emailError, setEmailError] = useState('');
193
195
  const [jobTitle, setJobTitle] = useState(record.jobTitle);
194
196
  const [department, setDepartment] = useState((_record_department = record.department) != null ? _record_department : '');
195
197
  const [birthdate, setBirthdate] = useState((_record_birthdate = record.birthdate) != null ? _record_birthdate : '');
@@ -242,6 +244,8 @@ export function ContactPropertiesCard(props) {
242
244
  setLastName((_record_lastName = record.lastName) != null ? _record_lastName : '');
243
245
  setPhones(phoneDraftsFrom(record));
244
246
  setDoNotCall(record.doNotCall === true);
247
+ setEmail(record.email);
248
+ setEmailError('');
245
249
  setJobTitle(record.jobTitle);
246
250
  setDepartment((_record_department = record.department) != null ? _record_department : '');
247
251
  setBirthdate((_record_birthdate = record.birthdate) != null ? _record_birthdate : '');
@@ -273,6 +277,19 @@ export function ContactPropertiesCard(props) {
273
277
  }, [
274
278
  recordId
275
279
  ]);
280
+ /*
281
+ * And it follows the document until somebody edits it (AGL-4122). A page
282
+ * drawn from the cache seeds these drafts from the cached row, and the
283
+ * server's answer arrives a moment later with the newer one. Left alone, the
284
+ * form kept the old values on screen with the seed reading confirmed, and the
285
+ * next save wrote them back over the new ones without anyone having touched
286
+ * those fields.
287
+ */ useEffect(()=>{
288
+ if (!edited) reseed();
289
+ // eslint-disable-next-line react-hooks/exhaustive-deps
290
+ }, [
291
+ record
292
+ ]);
276
293
  const handleSave = useCallback(async ()=>{
277
294
  var _record_salutation, _record_reportsToContactId;
278
295
  if (clearingRequired) return;
@@ -280,11 +297,20 @@ export function ContactPropertiesCard(props) {
280
297
  const storedBirthdate = normalizeContactBirthdate(birthdate);
281
298
  setPhoneErrors('errors' in readPhonesResult ? readPhonesResult.errors : {});
282
299
  setBirthdateError(storedBirthdate === null ? CONTACT_BIRTHDATE_REFUSAL : '');
300
+ // The address, only when it moved. It can be filled in or replaced, never
301
+ // emptied: a person who had one still answers to it (AGL-4122).
302
+ const typedEmail = email.trim();
303
+ const nextEmail = typedEmail === record.email ? null : normalizeContactEmail(typedEmail);
304
+ const emailMoved = typedEmail !== record.email;
305
+ setEmailError(emailMoved && !nextEmail ? 'Enter a valid email address.' : '');
283
306
  if ('errors' in readPhonesResult || storedBirthdate === null) return;
307
+ if (emailMoved && !nextEmail) return;
284
308
  // A stage that differs from the record's is the stage route's to write —
285
309
  // see the note above.
286
310
  const stageChanged = !suiteLocked && lifecycleStage !== record.lifecycleStage;
287
- const set = _extends({}, composedName ? {} : {
311
+ const set = _extends({}, nextEmail ? {
312
+ email: nextEmail
313
+ } : {}, composedName ? {} : {
288
314
  name: nameOverride.trim().slice(0, 120)
289
315
  }, {
290
316
  firstName: firstName.trim(),
@@ -330,21 +356,16 @@ export function ContactPropertiesCard(props) {
330
356
  logActivity('Updated contact', {
331
357
  type: 'contact',
332
358
  id: record.$id,
333
- name: record.name || record.email
359
+ name: record.name || email.trim() || record.phone
334
360
  });
335
361
  // The custom values are stored; the controls read the record again.
336
362
  setCustom({});
337
- if (stageChanged && !siteHostId) {
338
- // The stage lives on a site's facet, and this person has none. Never
339
- // "Contact saved" over a change that went nowhere (AGL-4120).
340
- return void enqueueSnackbar('Saved, but the stage could not be changed: no site has captured this contact yet.', {
341
- variant: 'warning',
342
- persist: false
343
- });
344
- }
345
- if (stageChanged && siteHostId) {
363
+ if (stageChanged) {
346
364
  try {
347
- await setContactStage(user, siteHostId, record.$id, lifecycleStage || null);
365
+ // A person no site holds is staged at the organization (AGL-4122).
366
+ await setContactStage(user, siteHostId, record.$id, lifecycleStage || null, {
367
+ orgId
368
+ });
348
369
  } catch (error) {
349
370
  // The rest of the profile is saved; only the stage was refused, and
350
371
  // the route's own sentence says why.
@@ -369,6 +390,8 @@ export function ContactPropertiesCard(props) {
369
390
  setSaving(false);
370
391
  }
371
392
  }, [
393
+ email,
394
+ orgId,
372
395
  address,
373
396
  assistantName,
374
397
  birthdate,
@@ -508,13 +531,11 @@ export function ContactPropertiesCard(props) {
508
531
  children: /*#__PURE__*/ _jsx(TextField, {
509
532
  size: "small",
510
533
  label: "Email",
511
- value: record.email,
512
- slotProps: {
513
- input: {
514
- readOnly: true
515
- }
516
- },
517
- helperText: "The identity every site shares — it cannot be edited here.",
534
+ type: "email",
535
+ value: email,
536
+ onChange: (event)=>change(setEmail, event.target.value),
537
+ error: Boolean(emailError),
538
+ helperText: emailError || (record.email ? 'Every site shares this address, so changing it changes it for all of them.' : 'Add it whenever you have it.'),
518
539
  fullWidth: true
519
540
  })
520
541
  }),