@aglyn/plugins-crm 1.0.0-beta.296 → 1.1.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 (161) 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 +135 -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-list-columns.js +5 -4
  25. package/src/lib/components/contact-list-columns.js.map +1 -1
  26. package/src/lib/components/contact-properties-card.js +41 -12
  27. package/src/lib/components/contact-properties-card.js.map +1 -1
  28. package/src/lib/components/contact-roles-editor.d.ts +54 -0
  29. package/src/lib/components/contact-roles-editor.js +270 -0
  30. package/src/lib/components/contact-roles-editor.js.map +1 -0
  31. package/src/lib/components/contacts-section.js +6 -4
  32. package/src/lib/components/contacts-section.js.map +1 -1
  33. package/src/lib/components/crm-console-sections.js +1 -1
  34. package/src/lib/components/crm-console-sections.js.map +1 -1
  35. package/src/lib/components/crm-record-picker.d.ts +19 -0
  36. package/src/lib/components/crm-record-picker.js +40 -11
  37. package/src/lib/components/crm-record-picker.js.map +1 -1
  38. package/src/lib/components/crm-related-to-picker.d.ts +33 -0
  39. package/src/lib/components/crm-related-to-picker.js +135 -0
  40. package/src/lib/components/crm-related-to-picker.js.map +1 -0
  41. package/src/lib/components/crm-site-picker.d.ts +13 -1
  42. package/src/lib/components/crm-site-picker.js +15 -6
  43. package/src/lib/components/crm-site-picker.js.map +1 -1
  44. package/src/lib/components/crm-transfer-buttons.d.ts +5 -0
  45. package/src/lib/components/crm-transfer-buttons.js +9 -6
  46. package/src/lib/components/crm-transfer-buttons.js.map +1 -1
  47. package/src/lib/components/deal-contact-roles-card.js +21 -239
  48. package/src/lib/components/deal-contact-roles-card.js.map +1 -1
  49. package/src/lib/components/deal-detail-page.js +2 -1
  50. package/src/lib/components/deal-detail-page.js.map +1 -1
  51. package/src/lib/components/deal-edit-drawer.js +11 -7
  52. package/src/lib/components/deal-edit-drawer.js.map +1 -1
  53. package/src/lib/components/deals-section.js +5 -4
  54. package/src/lib/components/deals-section.js.map +1 -1
  55. package/src/lib/components/lead-campaigns-card.js +26 -14
  56. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  57. package/src/lib/components/log-activity-dialog.d.ts +27 -9
  58. package/src/lib/components/log-activity-dialog.js +96 -32
  59. package/src/lib/components/log-activity-dialog.js.map +1 -1
  60. package/src/lib/components/new-contact-drawer.d.ts +7 -3
  61. package/src/lib/components/new-contact-drawer.js +27 -15
  62. package/src/lib/components/new-contact-drawer.js.map +1 -1
  63. package/src/lib/components/picklist-values-card.js +7 -4
  64. package/src/lib/components/picklist-values-card.js.map +1 -1
  65. package/src/lib/components/pipeline-stages-dialog.js +5 -1
  66. package/src/lib/components/pipeline-stages-dialog.js.map +1 -1
  67. package/src/lib/components/record-activity-card.d.ts +11 -2
  68. package/src/lib/components/record-activity-card.js +90 -5
  69. package/src/lib/components/record-activity-card.js.map +1 -1
  70. package/src/lib/components/settings-section.d.ts +4 -2
  71. package/src/lib/components/settings-section.js +7 -5
  72. package/src/lib/components/settings-section.js.map +1 -1
  73. package/src/lib/hooks/crm-activity-client-reads.d.ts +8 -0
  74. package/src/lib/hooks/crm-activity-client-reads.js +50 -0
  75. package/src/lib/hooks/crm-activity-client-reads.js.map +1 -0
  76. package/src/lib/hooks/use-campaign-filing-log.d.ts +2 -1
  77. package/src/lib/hooks/use-campaign-filing-log.js +10 -3
  78. package/src/lib/hooks/use-campaign-filing-log.js.map +1 -1
  79. package/src/lib/hooks/use-crm-org-mount.d.ts +8 -6
  80. package/src/lib/hooks/use-crm-org-mount.js +2 -1
  81. package/src/lib/hooks/use-crm-org-mount.js.map +1 -1
  82. package/src/lib/hooks/use-crm-scope.d.ts +17 -5
  83. package/src/lib/hooks/use-crm-scope.js +12 -5
  84. package/src/lib/hooks/use-crm-scope.js.map +1 -1
  85. package/src/lib/model/contact-holder.d.ts +12 -0
  86. package/src/lib/model/contact-holder.js +20 -1
  87. package/src/lib/model/contact-holder.js.map +1 -1
  88. package/src/lib/model/contact-record.d.ts +5 -0
  89. package/src/lib/model/contact-record.js +7 -1
  90. package/src/lib/model/contact-record.js.map +1 -1
  91. package/src/lib/model/contact-update.d.ts +6 -0
  92. package/src/lib/model/contact-update.js +1 -0
  93. package/src/lib/model/contact-update.js.map +1 -1
  94. package/src/lib/model/crm-activity-link.d.ts +117 -0
  95. package/src/lib/model/crm-activity-link.js +113 -0
  96. package/src/lib/model/crm-activity-link.js.map +1 -0
  97. package/src/lib/model/crm-api.d.ts +4 -1
  98. package/src/lib/model/crm-api.js +9 -5
  99. package/src/lib/model/crm-api.js.map +1 -1
  100. package/src/lib/model/deal-board-model.js +5 -3
  101. package/src/lib/model/deal-board-model.js.map +1 -1
  102. package/src/lib/model/deal-contact-roles.d.ts +15 -1
  103. package/src/lib/model/deal-contact-roles.js +17 -3
  104. package/src/lib/model/deal-contact-roles.js.map +1 -1
  105. package/src/lib/model/settle-stage-names.d.ts +25 -0
  106. package/src/lib/model/settle-stage-names.js +29 -0
  107. package/src/lib/model/settle-stage-names.js.map +1 -0
  108. package/src/lib/native-nav.js +1 -1
  109. package/src/lib/native-nav.js.map +1 -1
  110. package/src/lib/server/add-contact-by-hand.d.ts +84 -0
  111. package/src/lib/server/add-contact-by-hand.js +212 -0
  112. package/src/lib/server/add-contact-by-hand.js.map +1 -0
  113. package/src/lib/server/api-v1/contacts.js +61 -23
  114. package/src/lib/server/api-v1/contacts.js.map +1 -1
  115. package/src/lib/server/api-v1/crm-activities.js +13 -3
  116. package/src/lib/server/api-v1/crm-activities.js.map +1 -1
  117. package/src/lib/server/api-v1/crm-pipelines.d.ts +1 -1
  118. package/src/lib/server/api-v1/crm-pipelines.js +8 -2
  119. package/src/lib/server/api-v1/crm-pipelines.js.map +1 -1
  120. package/src/lib/server/api-v1/crm-shared.d.ts +16 -9
  121. package/src/lib/server/api-v1/crm-shared.js +23 -11
  122. package/src/lib/server/api-v1/crm-shared.js.map +1 -1
  123. package/src/lib/server/api-v1/openapi.js +3 -3
  124. package/src/lib/server/api-v1/openapi.js.map +1 -1
  125. package/src/lib/server/automation-steps.js +11 -9
  126. package/src/lib/server/automation-steps.js.map +1 -1
  127. package/src/lib/server/campaign-filing-activity.js +7 -2
  128. package/src/lib/server/campaign-filing-activity.js.map +1 -1
  129. package/src/lib/server/contact-update.d.ts +4 -0
  130. package/src/lib/server/contact-update.js +71 -10
  131. package/src/lib/server/contact-update.js.map +1 -1
  132. package/src/lib/server/convert-host-lead.js +13 -2
  133. package/src/lib/server/convert-host-lead.js.map +1 -1
  134. package/src/lib/server/convert-open-lead.js +8 -1
  135. package/src/lib/server/convert-open-lead.js.map +1 -1
  136. package/src/lib/server/crm-activity-company.d.ts +25 -0
  137. package/src/lib/server/crm-activity-company.js +34 -0
  138. package/src/lib/server/crm-activity-company.js.map +1 -0
  139. package/src/lib/server/crm-inbound-email.js +21 -15
  140. package/src/lib/server/crm-inbound-email.js.map +1 -1
  141. package/src/lib/server/crm-sharing.js +9 -2
  142. package/src/lib/server/crm-sharing.js.map +1 -1
  143. package/src/lib/server/email-send.js +10 -8
  144. package/src/lib/server/email-send.js.map +1 -1
  145. package/src/lib/server/merge-contacts.js +6 -2
  146. package/src/lib/server/merge-contacts.js.map +1 -1
  147. package/src/lib/server/org-caller.d.ts +8 -0
  148. package/src/lib/server/org-caller.js +10 -0
  149. package/src/lib/server/org-caller.js.map +1 -1
  150. package/src/lib/server/record-timeline.d.ts +3 -1
  151. package/src/lib/server/record-timeline.js +11 -3
  152. package/src/lib/server/record-timeline.js.map +1 -1
  153. package/src/lib/server/task-reminders-route.d.ts +7 -5
  154. package/src/lib/server/task-reminders-route.js +82 -15
  155. package/src/lib/server/task-reminders-route.js.map +1 -1
  156. package/src/lib/server.js +152 -43
  157. package/src/lib/server.js.map +1 -1
  158. package/src/lib/transfer/contacts.js +83 -48
  159. package/src/lib/transfer/contacts.js.map +1 -1
  160. package/src/lib/transfer/fields.js +1 -2
  161. package/src/lib/transfer/fields.js.map +1 -1
@@ -21,8 +21,11 @@ import { useSnackbar } from "@aglyn/shared-ui-snackstack";
21
21
  import { useUser, useUserName } from "@aglyn/tenant-feature-instance";
22
22
  import { Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, Stack, TextField } from "@mui/material";
23
23
  import { addDoc, collection, deleteField, doc, getCountFromServer, query, updateDoc, where } from "firebase/firestore";
24
- import { useCallback, useEffect, useState } from "react";
24
+ import { useCallback, useEffect, useRef, useState } from "react";
25
+ import { crmActivityClientReads } from "../hooks/crm-activity-client-reads.js";
25
26
  import { crmVisibleToClause } from "../hooks/use-crm-scope.js";
27
+ import { crmActivityLinkFromPicks, crmActivityPicksEqual, crmActivityPicksFrom, resolveCrmActivityCompany } from "../model/crm-activity-link.js";
28
+ import { ActivityLinkFields } from "./activity-link-fields.js";
26
29
  import { CrmSitePicker } from "./crm-site-picker.js";
27
30
  /** The longest body a single activity stores — a call summary, not a document. */ const BODY_MAX = 4000;
28
31
  const OUTCOME_MAX = 200;
@@ -43,14 +46,15 @@ const DURATION_MAX_MINUTES = 24 * 60;
43
46
  return Number.isFinite(ms) ? ms : null;
44
47
  }
45
48
  /** The kind a new activity opens on when the caller names none. */ const DEFAULT_KIND = 'call';
46
- const freshDraft = (kind = DEFAULT_KIND)=>({
49
+ const freshDraft = (kind = DEFAULT_KIND, picks = crmActivityPicksFrom({}))=>({
47
50
  kind,
48
51
  body: '',
49
52
  at: toLocalInput(Date.now()),
50
53
  outcome: '',
51
54
  durationMinutes: '',
52
55
  // Most logged calls and emails are the team's own.
53
- direction: 'outbound'
56
+ direction: 'outbound',
57
+ picks
54
58
  });
55
59
  const draftFrom = (activity)=>{
56
60
  var _activity_body, _activity_outcome, _Aglyn_crmActivityDirection;
@@ -60,13 +64,23 @@ const draftFrom = (activity)=>{
60
64
  at: toLocalInput(activity.atMs),
61
65
  outcome: (_activity_outcome = activity.outcome) != null ? _activity_outcome : '',
62
66
  durationMinutes: typeof activity.durationMinutes === 'number' ? String(activity.durationMinutes) : '',
63
- direction: (_Aglyn_crmActivityDirection = Aglyn.crmActivityDirection(activity.kind, activity.direction)) != null ? _Aglyn_crmActivityDirection : ''
67
+ direction: (_Aglyn_crmActivityDirection = Aglyn.crmActivityDirection(activity.kind, activity.direction)) != null ? _Aglyn_crmActivityDirection : '',
68
+ picks: crmActivityPicksFrom(activity)
64
69
  };
65
70
  };
66
71
  /**
67
72
  * Log a call, an email, a meeting, a note or something else against a
68
73
  * record (AGL-2600) — or correct one already logged.
69
74
  *
75
+ * ## Who, what and which account
76
+ *
77
+ * **Name** and **Related To** are Salesforce's Who and What (AGL-4123), and
78
+ * the company an entry rolls up to is stamped from them when it is saved —
79
+ * `model/crm-activity-link.ts` has the rule, shared with every other writer.
80
+ * The fields open on the record the dialog was opened from and the reader may
81
+ * change them: an entry named for a deal shows on the deal, on the person's
82
+ * timeline and on the company, whichever page it was logged from.
83
+ *
70
84
  * ## What is written
71
85
  *
72
86
  * A new activity carries the whole `CrmScoped` stamp: `visibleTo` from
@@ -79,9 +93,11 @@ const draftFrom = (activity)=>{
79
93
  * An edit rewrites only the fields the dialog shows. `visibleTo` is never
80
94
  * touched — the rules refuse a scoped member changing it, and widening a
81
95
  * record is an act for an org-wide member on the record itself — and neither
82
- * is the author or the link. A kind that stops taking an outcome takes the
83
- * outcome and the duration off the document rather than leaving a stale
84
- * "left a voicemail" on what is now a note.
96
+ * is the author. The links are rewritten only when **Name** or **Related To**
97
+ * changed, and then the company is worked out again, so an edit that corrects
98
+ * a typo cannot move an entry off a timeline it was filed on. A kind that
99
+ * stops taking an outcome takes the outcome and the duration off the document
100
+ * rather than leaving a stale "left a voicemail" on what is now a note.
85
101
  *
86
102
  * ## Who may write
87
103
  *
@@ -91,8 +107,9 @@ const draftFrom = (activity)=>{
91
107
  * so what reaches here has already passed the console's verdict.
92
108
  */ export function LogActivityDialog(props) {
93
109
  var _Aglyn_CRM_ACTIVITY_DIRECTIONS_draft_kind;
94
- const { open, onClose, scope, link, activity, kind: presetKind } = props;
95
- const { firestore, dataScope, hostId, mountedHostId, readTokens, writeTokens } = scope;
110
+ const { open, onClose, scope, link, deal, activity, kind: presetKind } = props;
111
+ const { firestore, dataScope, hostId, mountedHostId, readTokens, writeTokens, needsSite } = scope;
112
+ const { consentGroup, org } = scope;
96
113
  const { data: user } = useUser();
97
114
  const authorName = useUserName();
98
115
  const { enqueueSnackbar } = useSnackbar();
@@ -101,9 +118,27 @@ const draftFrom = (activity)=>{
101
118
  /*
102
119
  * Seeded on OPEN, not on mount: the dialog stays mounted between uses so
103
120
  * its state survives a close, and "log another call" must start from now
104
- * rather than from the last call's timestamp.
105
- */ useEffect(()=>{
106
- if (open) setDraft(activity ? draftFrom(activity) : freshDraft(presetKind));
121
+ * rather than from the last call's timestamp. The record is read through a
122
+ * ref, because a deal is a live document and a colleague's save to it must
123
+ * not wipe what the reader has typed so far.
124
+ */ const openedOn = useRef({
125
+ link,
126
+ deal
127
+ });
128
+ useEffect(()=>{
129
+ openedOn.current = {
130
+ link,
131
+ deal
132
+ };
133
+ }, [
134
+ link,
135
+ deal
136
+ ]);
137
+ useEffect(()=>{
138
+ if (!open) return;
139
+ const seed = openedOn.current;
140
+ const primary = seed.deal ? Aglyn.dealPrimaryContactId(Aglyn.dealContactRolesOf(seed.deal)) : null;
141
+ setDraft(activity ? draftFrom(activity) : freshDraft(presetKind, crmActivityPicksFrom(seed.link, primary)));
107
142
  }, [
108
143
  open,
109
144
  activity,
@@ -114,21 +149,36 @@ const draftFrom = (activity)=>{
114
149
  const direction = Aglyn.crmActivityDirection(draft.kind, draft.direction);
115
150
  const atMs = fromLocalInput(draft.at);
116
151
  const body = draft.body.trim();
152
+ /*
153
+ * A lead's own log shows neither field: a lead is not a contact **Name**
154
+ * could hold, and in Salesforce's model too it names no What. A contact
155
+ * beside the lead means it converted, and the entry reads like any other.
156
+ */ const filedOn = activity != null ? activity : link;
157
+ const leadOnly = Boolean(filedOn.leadId) && !filedOn.contactId;
158
+ const { picks } = draft;
159
+ // An activity attached to nothing is a sentence nobody can find (AGL-2606).
160
+ const linked = leadOnly || Boolean(picks.contactId || picks.related);
117
161
  const duration = draft.durationMinutes.trim() ? Number(draft.durationMinutes) : null;
118
162
  const durationValid = duration === null || Number.isInteger(duration) && duration >= 0 && duration <= DURATION_MAX_MINUTES;
119
- const canSave = Boolean(dataScope) && Boolean(user == null ? void 0 : user.uid) && // A NEW activity is stamped with a site's scope and provenance; at the
120
- // organization level that is the picked site, and there is nothing to
121
- // write until there is one (AGL-2630). An edit never touches either.
122
- (Boolean(activity) || Boolean(hostId)) && body.length > 0 && atMs !== null && durationValid && !saving;
163
+ const canSave = Boolean(dataScope) && Boolean(user == null ? void 0 : user.uid) && // A NEW activity is stamped with a scope: the whole org where every site
164
+ // sees the CRM's records, else the picked site's — and in an org that
165
+ // keeps its sites apart there is nothing to write until one is picked
166
+ // (AGL-2630). An edit never touches either.
167
+ (Boolean(activity) || writeTokens.length > 0) && body.length > 0 && atMs !== null && durationValid && linked && !saving;
123
168
  const handleSave = useCallback(async ()=>{
124
169
  // Every guard the button reads, again — a callback can outlive the
125
170
  // render that disabled it.
126
- if (!dataScope || !(user == null ? void 0 : user.uid) || !body || atMs === null || !durationValid) {
171
+ if (!dataScope || !(user == null ? void 0 : user.uid) || !body || atMs === null || !durationValid || !linked) {
127
172
  return;
128
173
  }
129
- if (!activity && !hostId) return;
174
+ if (!activity && !writeTokens.length) return;
130
175
  setSaving(true);
131
176
  try {
177
+ const named = crmActivityLinkFromPicks(picks, filedOn.leadId);
178
+ const stamped = ()=>resolveCrmActivityCompany(named, {
179
+ groupId: consentGroup == null ? void 0 : consentGroup.groupId,
180
+ org
181
+ }, crmActivityClientReads(firestore, dataScope[1]));
132
182
  const said = {
133
183
  kind: draft.kind,
134
184
  body: body.slice(0, BODY_MAX),
@@ -137,7 +187,13 @@ const draftFrom = (activity)=>{
137
187
  const outcome = hasOutcome ? draft.outcome.trim().slice(0, OUTCOME_MAX) : '';
138
188
  const durationMinutes = hasOutcome && duration !== null ? duration : null;
139
189
  if (activity) {
140
- await updateDoc(doc(firestore, dataScope[0], dataScope[1], Aglyn.CRM_COLLECTIONS.activities, activity.$id), _extends({}, said, {
190
+ var _relinked_contactId, _relinked_dealId, _relinked_companyId;
191
+ const relinked = crmActivityPicksEqual(picks, crmActivityPicksFrom(activity)) ? null : await stamped();
192
+ await updateDoc(doc(firestore, dataScope[0], dataScope[1], Aglyn.CRM_COLLECTIONS.activities, activity.$id), _extends({}, said, relinked ? {
193
+ contactId: (_relinked_contactId = relinked.contactId) != null ? _relinked_contactId : deleteField(),
194
+ dealId: (_relinked_dealId = relinked.dealId) != null ? _relinked_dealId : deleteField(),
195
+ companyId: (_relinked_companyId = relinked.companyId) != null ? _relinked_companyId : deleteField()
196
+ } : {}, {
141
197
  // Cleared rather than left: a call turned into a note must not
142
198
  // keep the call's outcome, and Firestore refuses `undefined`.
143
199
  outcome: outcome ? outcome : deleteField(),
@@ -157,7 +213,7 @@ const draftFrom = (activity)=>{
157
213
  * Scoped to the tokens this viewer may list, as every activity
158
214
  * listener is, so the rules admit the aggregate for a scoped
159
215
  * member exactly as they admit the timeline.
160
- */ const lead = Aglyn.crmActivityCeilingLink(link);
216
+ */ const lead = Aglyn.crmActivityCeilingLink(named);
161
217
  if (lead) {
162
218
  const logged = (await getCountFromServer(query(collection(firestore, dataScope[0], dataScope[1], Aglyn.CRM_COLLECTIONS.activities), ...crmVisibleToClause(readTokens), where(lead.field, '==', lead.id)))).data().count;
163
219
  if (!Aglyn.crmActivityLogHasRoom(logged)) {
@@ -168,21 +224,14 @@ const draftFrom = (activity)=>{
168
224
  return;
169
225
  }
170
226
  }
227
+ const links = await stamped();
171
228
  await addDoc(collection(firestore, dataScope[0], dataScope[1], Aglyn.CRM_COLLECTIONS.activities), _extends({}, said, outcome ? {
172
229
  outcome
173
230
  } : {}, durationMinutes !== null ? {
174
231
  durationMinutes
175
232
  } : {}, direction ? {
176
233
  direction
177
- } : {}, link.contactId ? {
178
- contactId: link.contactId
179
- } : {}, link.companyId ? {
180
- companyId: link.companyId
181
- } : {}, link.dealId ? {
182
- dealId: link.dealId
183
- } : {}, link.leadId ? {
184
- leadId: link.leadId
185
- } : {}, {
234
+ } : {}, links, {
186
235
  visibleTo: writeTokens,
187
236
  hostId,
188
237
  byUid: user.uid
@@ -221,7 +270,11 @@ const draftFrom = (activity)=>{
221
270
  activity,
222
271
  firestore,
223
272
  enqueueSnackbar,
224
- link,
273
+ linked,
274
+ picks,
275
+ filedOn.leadId,
276
+ consentGroup,
277
+ org,
225
278
  readTokens,
226
279
  writeTokens,
227
280
  hostId,
@@ -247,7 +300,8 @@ const draftFrom = (activity)=>{
247
300
  activity ? null : /*#__PURE__*/ _jsx(CrmSitePicker, {
248
301
  hostId: mountedHostId,
249
302
  disabled: saving,
250
- helperText: "The site this activity is logged from — it decides which of your sites may see it."
303
+ optional: needsSite === false,
304
+ helperText: needsSite === false ? undefined : 'The site this activity is logged from — it decides which of your sites may see it.'
251
305
  }),
252
306
  /*#__PURE__*/ _jsxs(Stack, {
253
307
  direction: "row",
@@ -380,7 +434,17 @@ const draftFrom = (activity)=>{
380
434
  }
381
435
  })
382
436
  ]
383
- }) : null
437
+ }) : null,
438
+ leadOnly ? null : /*#__PURE__*/ _jsx(ActivityLinkFields, {
439
+ scope: scope,
440
+ picks: picks,
441
+ onChange: (next)=>setDraft((prev)=>_extends({}, prev, {
442
+ picks: next
443
+ })),
444
+ link: link,
445
+ deal: deal,
446
+ disabled: saving
447
+ })
384
448
  ]
385
449
  }),
386
450
  /*#__PURE__*/ _jsxs(DialogActions, {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/log-activity-dialog.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport type { CrmActivityKind, CrmActivityRow } from '@aglyn/aglyn'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useUser, useUserName } from '@aglyn/tenant-feature-instance'\nimport {\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n MenuItem,\n Stack,\n TextField,\n} from '@mui/material'\nimport {\n addDoc,\n collection,\n deleteField,\n doc,\n getCountFromServer,\n query,\n updateDoc,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useState } from 'react'\nimport { crmVisibleToClause } from '../hooks/use-crm-scope'\nimport type { ActivityRecordLink, ActivityScope } from './activity-queries'\nimport { CrmSitePicker } from './crm-site-picker'\n\n/** The longest body a single activity stores — a call summary, not a document. */\nconst BODY_MAX = 4000\nconst OUTCOME_MAX = 200\nconst DURATION_MAX_MINUTES = 24 * 60\n\n/**\n * Epoch millis as the value a `datetime-local` input holds, in the LOCAL\n * zone — the input has no zone of its own, so an ISO string with a `Z`\n * would land the call an offset away from when it happened.\n */\nfunction toLocalInput(ms: number): string {\n if (!Number.isFinite(ms)) return ''\n const date = new Date(ms)\n const pad = (value: number) => String(value).padStart(2, '0')\n return (\n `${date.getFullYear()}-${pad(date.getMonth() + 1)}-` +\n `${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`\n )\n}\n\n/** The inverse: a `datetime-local` value parsed as local time, or `null`. */\nfunction fromLocalInput(value: string): number | null {\n if (!value) return null\n const ms = new Date(value).getTime()\n return Number.isFinite(ms) ? ms : null\n}\n\ninterface Draft {\n kind: CrmActivityKind\n body: string\n /** The `datetime-local` string, not millis — what the input holds. */\n at: string\n outcome: string\n /** Free text until save, so a half-typed number does not snap to zero. */\n durationMinutes: string\n /**\n * Which way a call or an email went (AGL-3517) — Salesforce's Call Type\n * for a call — or `''` for not said. Kept across a kind change and\n * written only when the kind takes it.\n */\n direction: string\n}\n\n/** The kind a new activity opens on when the caller names none. */\nconst DEFAULT_KIND: CrmActivityKind = 'call'\n\nconst freshDraft = (kind: CrmActivityKind = DEFAULT_KIND): Draft => ({\n kind,\n body: '',\n at: toLocalInput(Date.now()),\n outcome: '',\n durationMinutes: '',\n // Most logged calls and emails are the team's own.\n direction: 'outbound',\n})\n\nconst draftFrom = (activity: CrmActivityRow): Draft => ({\n kind: Aglyn.isCrmActivityKind(activity.kind) ? activity.kind : 'other',\n body: activity.body ?? '',\n at: toLocalInput(activity.atMs),\n outcome: activity.outcome ?? '',\n durationMinutes:\n typeof activity.durationMinutes === 'number'\n ? String(activity.durationMinutes)\n : '',\n direction: Aglyn.crmActivityDirection(activity.kind, activity.direction) ?? '',\n})\n\nexport interface LogActivityDialogProps {\n open: boolean\n onClose: () => void\n /** Where the activity is written, from `useActivityScope`. */\n scope: ActivityScope\n /**\n * The record the activity is filed against, FIXED by the caller. A\n * contact's page logs against that contact; the dialog offers no picker,\n * because an activity logged from a record page is about that record and\n * a picker would be a way to file it somewhere the reader is not looking.\n */\n link: ActivityRecordLink\n /**\n * When set, the dialog EDITS this activity rather than logging a new one.\n * The link, the author and the scope stay what they were; only what was\n * said, when, and how it went can change.\n */\n activity?: CrmActivityRow | null\n /**\n * What a NEW activity opens as (AGL-2661) — the click-to-call button\n * opens the dialog already a call, so logging one is the button and a\n * sentence rather than the button, the kind and a sentence. Ignored for\n * an edit, whose kind is the activity's own.\n */\n kind?: CrmActivityKind\n}\n\n/**\n * Log a call, an email, a meeting, a note or something else against a\n * record (AGL-2600) — or correct one already logged.\n *\n * ## What is written\n *\n * A new activity carries the whole `CrmScoped` stamp: `visibleTo` from\n * `crmScopeTokens`, so it lands in exactly the scope a contact captured on\n * this site would; `hostId` as provenance; `byUid` and `byName` for the\n * author, the name denormalized because a colleague reading the log cannot\n * resolve a uid (see `CrmActivity.byName`); `atMs` for when it HAPPENED,\n * which the reader picks and which defaults to now; and the timestamps.\n *\n * An edit rewrites only the fields the dialog shows. `visibleTo` is never\n * touched — the rules refuse a scoped member changing it, and widening a\n * record is an act for an org-wide member on the record itself — and neither\n * is the author or the link. A kind that stops taking an outcome takes the\n * outcome and the duration off the document rather than leaving a stale\n * \"left a voicemail\" on what is now a note.\n *\n * ## Who may write\n *\n * The rules admit any member who may write scoped org data, on create and\n * on update alike; they do not distinguish the author. The card that opens\n * this dialog for an edit checks the author itself (`useCanEditActivity`),\n * so what reaches here has already passed the console's verdict.\n */\nexport function LogActivityDialog(props: LogActivityDialogProps) {\n const { open, onClose, scope, link, activity, kind: presetKind } = props\n const { firestore, dataScope, hostId, mountedHostId, readTokens, writeTokens } =\n scope\n const { data: user } = useUser()\n const authorName = useUserName()\n const { enqueueSnackbar } = useSnackbar()\n const [draft, setDraft] = useState<Draft>(freshDraft)\n const [saving, setSaving] = useState(false)\n\n /*\n * Seeded on OPEN, not on mount: the dialog stays mounted between uses so\n * its state survives a close, and \"log another call\" must start from now\n * rather than from the last call's timestamp.\n */\n useEffect(() => {\n if (open) setDraft(activity ? draftFrom(activity) : freshDraft(presetKind))\n }, [open, activity, presetKind])\n\n const hasOutcome = Aglyn.activityKindHasOutcome(draft.kind)\n const directions = Aglyn.CRM_ACTIVITY_DIRECTIONS[draft.kind] ?? []\n const direction = Aglyn.crmActivityDirection(draft.kind, draft.direction)\n const atMs = fromLocalInput(draft.at)\n const body = draft.body.trim()\n const duration = draft.durationMinutes.trim()\n ? Number(draft.durationMinutes)\n : null\n const durationValid =\n duration === null ||\n (Number.isInteger(duration) &&\n duration >= 0 &&\n duration <= DURATION_MAX_MINUTES)\n const canSave =\n Boolean(dataScope) &&\n Boolean(user?.uid) &&\n // A NEW activity is stamped with a site's scope and provenance; at the\n // organization level that is the picked site, and there is nothing to\n // write until there is one (AGL-2630). An edit never touches either.\n (Boolean(activity) || Boolean(hostId)) &&\n body.length > 0 &&\n atMs !== null &&\n durationValid &&\n !saving\n\n const handleSave = useCallback(async () => {\n // Every guard the button reads, again — a callback can outlive the\n // render that disabled it.\n if (!dataScope || !user?.uid || !body || atMs === null || !durationValid) {\n return\n }\n if (!activity && !hostId) return\n setSaving(true)\n try {\n const said = {\n kind: draft.kind,\n body: body.slice(0, BODY_MAX),\n atMs,\n }\n const outcome = hasOutcome ? draft.outcome.trim().slice(0, OUTCOME_MAX) : ''\n const durationMinutes = hasOutcome && duration !== null ? duration : null\n if (activity) {\n await updateDoc(\n doc(\n firestore,\n dataScope[0],\n dataScope[1],\n Aglyn.CRM_COLLECTIONS.activities,\n activity.$id,\n ),\n {\n ...said,\n // Cleared rather than left: a call turned into a note must not\n // keep the call's outcome, and Firestore refuses `undefined`.\n outcome: outcome ? outcome : deleteField(),\n direction: direction ?? deleteField(),\n durationMinutes:\n durationMinutes !== null ? durationMinutes : deleteField(),\n updatedAt: new Date(),\n },\n )\n enqueueSnackbar('Activity updated', { variant: 'success', persist: false })\n } else {\n /*\n * THE PER-RECORD CEILING (AGL-2611): one aggregate on the record\n * this dialog is filing under, before the write — the same count\n * the automation step and the REST create take, on the same link.\n * Scoped to the tokens this viewer may list, as every activity\n * listener is, so the rules admit the aggregate for a scoped\n * member exactly as they admit the timeline.\n */\n const lead = Aglyn.crmActivityCeilingLink(link)\n if (lead) {\n const logged = (\n await getCountFromServer(\n query(\n collection(\n firestore,\n dataScope[0],\n dataScope[1],\n Aglyn.CRM_COLLECTIONS.activities,\n ),\n ...crmVisibleToClause(readTokens),\n where(lead.field, '==', lead.id),\n ),\n )\n ).data().count\n if (!Aglyn.crmActivityLogHasRoom(logged)) {\n enqueueSnackbar(Aglyn.CRM_ACTIVITY_LOG_FULL_MESSAGE, {\n variant: 'warning',\n persist: false,\n })\n return\n }\n }\n await addDoc(\n collection(\n firestore,\n dataScope[0],\n dataScope[1],\n Aglyn.CRM_COLLECTIONS.activities,\n ),\n {\n ...said,\n ...(outcome ? { outcome } : {}),\n ...(durationMinutes !== null ? { durationMinutes } : {}),\n ...(direction ? { direction } : {}),\n // Only the link the caller fixed. A key with no value is\n // `undefined`, which Firestore refuses, so each is spread in\n // rather than written blank.\n ...(link.contactId ? { contactId: link.contactId } : {}),\n ...(link.companyId ? { companyId: link.companyId } : {}),\n ...(link.dealId ? { dealId: link.dealId } : {}),\n ...(link.leadId ? { leadId: link.leadId } : {}),\n visibleTo: writeTokens,\n hostId,\n byUid: user.uid,\n // Absent rather than blank while the profile is still resolving:\n // the row reads a stored name over anything else, and an empty\n // string would be a name.\n ...(authorName ? { byName: authorName } : {}),\n createdAt: new Date(),\n updatedAt: new Date(),\n },\n )\n enqueueSnackbar('Activity logged', { variant: 'success', persist: false })\n }\n onClose()\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setSaving(false)\n }\n }, [\n dataScope,\n user,\n body,\n atMs,\n durationValid,\n draft.kind,\n draft.outcome,\n direction,\n hasOutcome,\n duration,\n activity,\n firestore,\n enqueueSnackbar,\n link,\n readTokens,\n writeTokens,\n hostId,\n authorName,\n onClose,\n ])\n\n return (\n <Dialog open={open} onClose={onClose} maxWidth=\"sm\" fullWidth>\n <DialogTitle>{activity ? 'Edit activity' : 'Log activity'}</DialogTitle>\n <DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>\n {/* Only for a new activity at the organization level — see `canSave`. */}\n {activity ? null : (\n <CrmSitePicker\n hostId={mountedHostId}\n disabled={saving}\n helperText=\"The site this activity is logged from — it decides which of your sites may see it.\"\n />\n )}\n <Stack direction=\"row\" spacing={1} sx={{ mt: 1 }}>\n <TextField\n select\n size=\"small\"\n label=\"Kind\"\n value={draft.kind}\n onChange={(event) =>\n setDraft((prev) => ({\n ...prev,\n kind: Aglyn.isCrmActivityKind(event.target.value)\n ? event.target.value\n : prev.kind,\n }))\n }\n sx={{ minWidth: 140 }}\n >\n {Aglyn.CRM_ACTIVITY_KINDS.map((kind) => (\n <MenuItem key={kind} value={kind}>\n {Aglyn.CRM_ACTIVITY_KIND_LABELS[kind]}\n </MenuItem>\n ))}\n </TextField>\n {directions.length ? (\n <TextField\n select\n size=\"small\"\n label=\"Direction\"\n value={direction ?? ''}\n onChange={(event) =>\n setDraft((prev) => ({ ...prev, direction: String(event.target.value) }))\n }\n slotProps={{ select: { displayEmpty: true }, inputLabel: { shrink: true } }}\n sx={{ minWidth: 130 }}\n >\n <MenuItem value=\"\">{'Not said'}</MenuItem>\n {directions.map((entry) => (\n <MenuItem key={entry} value={entry}>\n {Aglyn.CRM_ACTIVITY_DIRECTION_LABELS[entry]}\n </MenuItem>\n ))}\n </TextField>\n ) : null}\n <TextField\n size=\"small\"\n label=\"When\"\n type=\"datetime-local\"\n value={draft.at}\n onChange={(event) =>\n setDraft((prev) => ({ ...prev, at: event.target.value }))\n }\n error={draft.at.length > 0 && atMs === null}\n slotProps={{ inputLabel: { shrink: true } }}\n sx={{ flex: 1 }}\n />\n </Stack>\n <TextField\n size=\"small\"\n label=\"What happened\"\n value={draft.body}\n onChange={(event) =>\n setDraft((prev) => ({ ...prev, body: event.target.value }))\n }\n multiline\n minRows={3}\n autoFocus\n slotProps={{ htmlInput: { maxLength: BODY_MAX } }}\n />\n {hasOutcome ? (\n <Stack direction=\"row\" spacing={1}>\n <TextField\n size=\"small\"\n label=\"Outcome\"\n placeholder=\"Left a voicemail\"\n value={draft.outcome}\n onChange={(event) =>\n setDraft((prev) => ({ ...prev, outcome: event.target.value }))\n }\n slotProps={{ htmlInput: { maxLength: OUTCOME_MAX } }}\n sx={{ flex: 1 }}\n />\n <TextField\n size=\"small\"\n label=\"Minutes\"\n type=\"number\"\n value={draft.durationMinutes}\n onChange={(event) =>\n setDraft((prev) => ({\n ...prev,\n durationMinutes: event.target.value,\n }))\n }\n error={!durationValid}\n slotProps={{\n htmlInput: { min: 0, max: DURATION_MAX_MINUTES, step: 1 },\n }}\n sx={{ width: 120 }}\n />\n </Stack>\n ) : null}\n </DialogContent>\n <DialogActions>\n <Button onClick={onClose} disabled={saving}>\n {'Cancel'}\n </Button>\n <Button\n variant=\"contained\"\n color=\"primary\"\n disabled={!canSave}\n onClick={handleSave}\n >\n {activity ? 'Save' : 'Log'}\n </Button>\n </DialogActions>\n </Dialog>\n )\n}\nLogActivityDialog.displayName = 'LogActivityDialog'\n\nexport default LogActivityDialog\n"],"names":["Aglyn","useSnackbar","useUser","useUserName","Button","Dialog","DialogActions","DialogContent","DialogTitle","MenuItem","Stack","TextField","addDoc","collection","deleteField","doc","getCountFromServer","query","updateDoc","where","useCallback","useEffect","useState","crmVisibleToClause","CrmSitePicker","BODY_MAX","OUTCOME_MAX","DURATION_MAX_MINUTES","toLocalInput","ms","Number","isFinite","date","Date","pad","value","String","padStart","getFullYear","getMonth","getDate","getHours","getMinutes","fromLocalInput","getTime","DEFAULT_KIND","freshDraft","kind","body","at","now","outcome","durationMinutes","direction","draftFrom","activity","isCrmActivityKind","atMs","crmActivityDirection","LogActivityDialog","props","open","onClose","scope","link","presetKind","firestore","dataScope","hostId","mountedHostId","readTokens","writeTokens","data","user","authorName","enqueueSnackbar","draft","setDraft","saving","setSaving","hasOutcome","activityKindHasOutcome","directions","CRM_ACTIVITY_DIRECTIONS","trim","duration","durationValid","isInteger","canSave","Boolean","uid","length","handleSave","said","slice","CRM_COLLECTIONS","activities","$id","updatedAt","variant","persist","lead","crmActivityCeilingLink","logged","field","id","count","crmActivityLogHasRoom","CRM_ACTIVITY_LOG_FULL_MESSAGE","contactId","companyId","dealId","leadId","visibleTo","byUid","byName","createdAt","error","console","allowDuplicate","maxWidth","fullWidth","sx","display","flexDirection","gap","disabled","helperText","spacing","mt","select","size","label","onChange","event","prev","target","minWidth","CRM_ACTIVITY_KINDS","map","CRM_ACTIVITY_KIND_LABELS","slotProps","displayEmpty","inputLabel","shrink","entry","CRM_ACTIVITY_DIRECTION_LABELS","type","flex","multiline","minRows","autoFocus","htmlInput","maxLength","placeholder","min","max","step","width","onClick","color","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/log-activity-dialog.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport type { CrmActivityKind, CrmActivityRow } from '@aglyn/aglyn'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { useUser, useUserName } from '@aglyn/tenant-feature-instance'\nimport {\n Button,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n MenuItem,\n Stack,\n TextField,\n} from '@mui/material'\nimport {\n addDoc,\n collection,\n deleteField,\n doc,\n getCountFromServer,\n query,\n updateDoc,\n where,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { crmActivityClientReads } from '../hooks/crm-activity-client-reads'\nimport { crmVisibleToClause } from '../hooks/use-crm-scope'\nimport {\n crmActivityLinkFromPicks,\n crmActivityPicksEqual,\n crmActivityPicksFrom,\n type CrmActivityPicks,\n resolveCrmActivityCompany,\n} from '../model/crm-activity-link'\nimport { ActivityLinkFields } from './activity-link-fields'\nimport type { ActivityRecordLink, ActivityScope } from './activity-queries'\nimport { CrmSitePicker } from './crm-site-picker'\n\n/** The longest body a single activity stores — a call summary, not a document. */\nconst BODY_MAX = 4000\nconst OUTCOME_MAX = 200\nconst DURATION_MAX_MINUTES = 24 * 60\n\n/**\n * Epoch millis as the value a `datetime-local` input holds, in the LOCAL\n * zone — the input has no zone of its own, so an ISO string with a `Z`\n * would land the call an offset away from when it happened.\n */\nfunction toLocalInput(ms: number): string {\n if (!Number.isFinite(ms)) return ''\n const date = new Date(ms)\n const pad = (value: number) => String(value).padStart(2, '0')\n return (\n `${date.getFullYear()}-${pad(date.getMonth() + 1)}-` +\n `${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`\n )\n}\n\n/** The inverse: a `datetime-local` value parsed as local time, or `null`. */\nfunction fromLocalInput(value: string): number | null {\n if (!value) return null\n const ms = new Date(value).getTime()\n return Number.isFinite(ms) ? ms : null\n}\n\ninterface Draft {\n kind: CrmActivityKind\n body: string\n /** The `datetime-local` string, not millis — what the input holds. */\n at: string\n outcome: string\n /** Free text until save, so a half-typed number does not snap to zero. */\n durationMinutes: string\n /**\n * Which way a call or an email went (AGL-3517) — Salesforce's Call Type\n * for a call — or `''` for not said. Kept across a kind change and\n * written only when the kind takes it.\n */\n direction: string\n /** **Name** and **Related To**, as the reader has left them. */\n picks: CrmActivityPicks\n}\n\n/** The kind a new activity opens on when the caller names none. */\nconst DEFAULT_KIND: CrmActivityKind = 'call'\n\nconst freshDraft = (\n kind: CrmActivityKind = DEFAULT_KIND,\n picks: CrmActivityPicks = crmActivityPicksFrom({}),\n): Draft => ({\n kind,\n body: '',\n at: toLocalInput(Date.now()),\n outcome: '',\n durationMinutes: '',\n // Most logged calls and emails are the team's own.\n direction: 'outbound',\n picks,\n})\n\nconst draftFrom = (activity: CrmActivityRow): Draft => ({\n kind: Aglyn.isCrmActivityKind(activity.kind) ? activity.kind : 'other',\n body: activity.body ?? '',\n at: toLocalInput(activity.atMs),\n outcome: activity.outcome ?? '',\n durationMinutes:\n typeof activity.durationMinutes === 'number'\n ? String(activity.durationMinutes)\n : '',\n direction: Aglyn.crmActivityDirection(activity.kind, activity.direction) ?? '',\n picks: crmActivityPicksFrom(activity),\n})\n\nexport interface LogActivityDialogProps {\n open: boolean\n onClose: () => void\n /** Where the activity is written, from `useActivityScope`. */\n scope: ActivityScope\n /**\n * The record the dialog was opened on, which prefills **Name** and\n * **Related To** (AGL-4123). A lead's own log offers neither: the lead is\n * the entry's only link until it converts.\n */\n link: ActivityRecordLink\n /**\n * The deal the dialog was opened on, as its page read it: **Name** opens on\n * its Primary and its contact roles lead the list.\n */\n deal?: { contactId?: unknown; contactRoles?: unknown } | null\n /**\n * When set, the dialog EDITS this activity rather than logging a new one.\n * The author and the scope stay what they were; what was said, when, how\n * it went, and who and what it is about can change.\n */\n activity?: CrmActivityRow | null\n /**\n * What a NEW activity opens as (AGL-2661) — the click-to-call button\n * opens the dialog already a call, so logging one is the button and a\n * sentence rather than the button, the kind and a sentence. Ignored for\n * an edit, whose kind is the activity's own.\n */\n kind?: CrmActivityKind\n}\n\n/**\n * Log a call, an email, a meeting, a note or something else against a\n * record (AGL-2600) — or correct one already logged.\n *\n * ## Who, what and which account\n *\n * **Name** and **Related To** are Salesforce's Who and What (AGL-4123), and\n * the company an entry rolls up to is stamped from them when it is saved —\n * `model/crm-activity-link.ts` has the rule, shared with every other writer.\n * The fields open on the record the dialog was opened from and the reader may\n * change them: an entry named for a deal shows on the deal, on the person's\n * timeline and on the company, whichever page it was logged from.\n *\n * ## What is written\n *\n * A new activity carries the whole `CrmScoped` stamp: `visibleTo` from\n * `crmScopeTokens`, so it lands in exactly the scope a contact captured on\n * this site would; `hostId` as provenance; `byUid` and `byName` for the\n * author, the name denormalized because a colleague reading the log cannot\n * resolve a uid (see `CrmActivity.byName`); `atMs` for when it HAPPENED,\n * which the reader picks and which defaults to now; and the timestamps.\n *\n * An edit rewrites only the fields the dialog shows. `visibleTo` is never\n * touched — the rules refuse a scoped member changing it, and widening a\n * record is an act for an org-wide member on the record itself — and neither\n * is the author. The links are rewritten only when **Name** or **Related To**\n * changed, and then the company is worked out again, so an edit that corrects\n * a typo cannot move an entry off a timeline it was filed on. A kind that\n * stops taking an outcome takes the outcome and the duration off the document\n * rather than leaving a stale \"left a voicemail\" on what is now a note.\n *\n * ## Who may write\n *\n * The rules admit any member who may write scoped org data, on create and\n * on update alike; they do not distinguish the author. The card that opens\n * this dialog for an edit checks the author itself (`useCanEditActivity`),\n * so what reaches here has already passed the console's verdict.\n */\nexport function LogActivityDialog(props: LogActivityDialogProps) {\n const { open, onClose, scope, link, deal, activity, kind: presetKind } = props\n const { firestore, dataScope, hostId, mountedHostId, readTokens, writeTokens, needsSite } =\n scope\n const { consentGroup, org } = scope\n const { data: user } = useUser()\n const authorName = useUserName()\n const { enqueueSnackbar } = useSnackbar()\n const [draft, setDraft] = useState<Draft>(freshDraft)\n const [saving, setSaving] = useState(false)\n\n /*\n * Seeded on OPEN, not on mount: the dialog stays mounted between uses so\n * its state survives a close, and \"log another call\" must start from now\n * rather than from the last call's timestamp. The record is read through a\n * ref, because a deal is a live document and a colleague's save to it must\n * not wipe what the reader has typed so far.\n */\n const openedOn = useRef({ link, deal })\n useEffect(() => {\n openedOn.current = { link, deal }\n }, [link, deal])\n useEffect(() => {\n if (!open) return\n const seed = openedOn.current\n const primary = seed.deal ? Aglyn.dealPrimaryContactId(Aglyn.dealContactRolesOf(seed.deal)) : null\n setDraft(\n activity\n ? draftFrom(activity)\n : freshDraft(presetKind, crmActivityPicksFrom(seed.link, primary)),\n )\n }, [open, activity, presetKind])\n\n const hasOutcome = Aglyn.activityKindHasOutcome(draft.kind)\n const directions = Aglyn.CRM_ACTIVITY_DIRECTIONS[draft.kind] ?? []\n const direction = Aglyn.crmActivityDirection(draft.kind, draft.direction)\n const atMs = fromLocalInput(draft.at)\n const body = draft.body.trim()\n /*\n * A lead's own log shows neither field: a lead is not a contact **Name**\n * could hold, and in Salesforce's model too it names no What. A contact\n * beside the lead means it converted, and the entry reads like any other.\n */\n const filedOn = activity ?? link\n const leadOnly = Boolean(filedOn.leadId) && !filedOn.contactId\n const { picks } = draft\n // An activity attached to nothing is a sentence nobody can find (AGL-2606).\n const linked = leadOnly || Boolean(picks.contactId || picks.related)\n const duration = draft.durationMinutes.trim()\n ? Number(draft.durationMinutes)\n : null\n const durationValid =\n duration === null ||\n (Number.isInteger(duration) &&\n duration >= 0 &&\n duration <= DURATION_MAX_MINUTES)\n const canSave =\n Boolean(dataScope) &&\n Boolean(user?.uid) &&\n // A NEW activity is stamped with a scope: the whole org where every site\n // sees the CRM's records, else the picked site's — and in an org that\n // keeps its sites apart there is nothing to write until one is picked\n // (AGL-2630). An edit never touches either.\n (Boolean(activity) || writeTokens.length > 0) &&\n body.length > 0 &&\n atMs !== null &&\n durationValid &&\n linked &&\n !saving\n\n const handleSave = useCallback(async () => {\n // Every guard the button reads, again — a callback can outlive the\n // render that disabled it.\n if (!dataScope || !user?.uid || !body || atMs === null || !durationValid || !linked) {\n return\n }\n if (!activity && !writeTokens.length) return\n setSaving(true)\n try {\n const named = crmActivityLinkFromPicks(picks, filedOn.leadId)\n const stamped = () =>\n resolveCrmActivityCompany(\n named,\n { groupId: consentGroup?.groupId, org },\n crmActivityClientReads(firestore, dataScope[1]),\n )\n const said = {\n kind: draft.kind,\n body: body.slice(0, BODY_MAX),\n atMs,\n }\n const outcome = hasOutcome ? draft.outcome.trim().slice(0, OUTCOME_MAX) : ''\n const durationMinutes = hasOutcome && duration !== null ? duration : null\n if (activity) {\n const relinked = crmActivityPicksEqual(picks, crmActivityPicksFrom(activity))\n ? null\n : await stamped()\n await updateDoc(\n doc(\n firestore,\n dataScope[0],\n dataScope[1],\n Aglyn.CRM_COLLECTIONS.activities,\n activity.$id,\n ),\n {\n ...said,\n // Cleared rather than left: an entry moved off a deal must stop\n // showing on it. A lead's own link is never the fields' to change.\n ...(relinked\n ? {\n contactId: relinked.contactId ?? deleteField(),\n dealId: relinked.dealId ?? deleteField(),\n companyId: relinked.companyId ?? deleteField(),\n }\n : {}),\n // Cleared rather than left: a call turned into a note must not\n // keep the call's outcome, and Firestore refuses `undefined`.\n outcome: outcome ? outcome : deleteField(),\n direction: direction ?? deleteField(),\n durationMinutes:\n durationMinutes !== null ? durationMinutes : deleteField(),\n updatedAt: new Date(),\n },\n )\n enqueueSnackbar('Activity updated', { variant: 'success', persist: false })\n } else {\n /*\n * THE PER-RECORD CEILING (AGL-2611): one aggregate on the record\n * this dialog is filing under, before the write — the same count\n * the automation step and the REST create take, on the same link.\n * Scoped to the tokens this viewer may list, as every activity\n * listener is, so the rules admit the aggregate for a scoped\n * member exactly as they admit the timeline.\n */\n const lead = Aglyn.crmActivityCeilingLink(named)\n if (lead) {\n const logged = (\n await getCountFromServer(\n query(\n collection(\n firestore,\n dataScope[0],\n dataScope[1],\n Aglyn.CRM_COLLECTIONS.activities,\n ),\n ...crmVisibleToClause(readTokens),\n where(lead.field, '==', lead.id),\n ),\n )\n ).data().count\n if (!Aglyn.crmActivityLogHasRoom(logged)) {\n enqueueSnackbar(Aglyn.CRM_ACTIVITY_LOG_FULL_MESSAGE, {\n variant: 'warning',\n persist: false,\n })\n return\n }\n }\n const links = await stamped()\n await addDoc(\n collection(\n firestore,\n dataScope[0],\n dataScope[1],\n Aglyn.CRM_COLLECTIONS.activities,\n ),\n {\n ...said,\n ...(outcome ? { outcome } : {}),\n ...(durationMinutes !== null ? { durationMinutes } : {}),\n ...(direction ? { direction } : {}),\n // Only the links it has: `stampCrmActivityCompany` leaves no blank\n // key, and Firestore refuses `undefined`.\n ...links,\n visibleTo: writeTokens,\n hostId,\n byUid: user.uid,\n // Absent rather than blank while the profile is still resolving:\n // the row reads a stored name over anything else, and an empty\n // string would be a name.\n ...(authorName ? { byName: authorName } : {}),\n createdAt: new Date(),\n updatedAt: new Date(),\n },\n )\n enqueueSnackbar('Activity logged', { variant: 'success', persist: false })\n }\n onClose()\n } catch (error) {\n console.error(error)\n enqueueSnackbar('An error has occurred', {\n variant: 'error',\n allowDuplicate: true,\n })\n } finally {\n setSaving(false)\n }\n }, [\n dataScope,\n user,\n body,\n atMs,\n durationValid,\n draft.kind,\n draft.outcome,\n direction,\n hasOutcome,\n duration,\n activity,\n firestore,\n enqueueSnackbar,\n linked,\n picks,\n filedOn.leadId,\n consentGroup,\n org,\n readTokens,\n writeTokens,\n hostId,\n authorName,\n onClose,\n ])\n\n return (\n <Dialog open={open} onClose={onClose} maxWidth=\"sm\" fullWidth>\n <DialogTitle>{activity ? 'Edit activity' : 'Log activity'}</DialogTitle>\n <DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>\n {/* Only for a new activity at the organization level — see `canSave`. */}\n {activity ? null : (\n <CrmSitePicker\n hostId={mountedHostId}\n disabled={saving}\n optional={needsSite === false}\n helperText={\n needsSite === false\n ? undefined\n : 'The site this activity is logged from — it decides which of your sites may see it.'\n }\n />\n )}\n <Stack direction=\"row\" spacing={1} sx={{ mt: 1 }}>\n <TextField\n select\n size=\"small\"\n label=\"Kind\"\n value={draft.kind}\n onChange={(event) =>\n setDraft((prev) => ({\n ...prev,\n kind: Aglyn.isCrmActivityKind(event.target.value)\n ? event.target.value\n : prev.kind,\n }))\n }\n sx={{ minWidth: 140 }}\n >\n {Aglyn.CRM_ACTIVITY_KINDS.map((kind) => (\n <MenuItem key={kind} value={kind}>\n {Aglyn.CRM_ACTIVITY_KIND_LABELS[kind]}\n </MenuItem>\n ))}\n </TextField>\n {directions.length ? (\n <TextField\n select\n size=\"small\"\n label=\"Direction\"\n value={direction ?? ''}\n onChange={(event) =>\n setDraft((prev) => ({ ...prev, direction: String(event.target.value) }))\n }\n slotProps={{ select: { displayEmpty: true }, inputLabel: { shrink: true } }}\n sx={{ minWidth: 130 }}\n >\n <MenuItem value=\"\">{'Not said'}</MenuItem>\n {directions.map((entry) => (\n <MenuItem key={entry} value={entry}>\n {Aglyn.CRM_ACTIVITY_DIRECTION_LABELS[entry]}\n </MenuItem>\n ))}\n </TextField>\n ) : null}\n <TextField\n size=\"small\"\n label=\"When\"\n type=\"datetime-local\"\n value={draft.at}\n onChange={(event) =>\n setDraft((prev) => ({ ...prev, at: event.target.value }))\n }\n error={draft.at.length > 0 && atMs === null}\n slotProps={{ inputLabel: { shrink: true } }}\n sx={{ flex: 1 }}\n />\n </Stack>\n <TextField\n size=\"small\"\n label=\"What happened\"\n value={draft.body}\n onChange={(event) =>\n setDraft((prev) => ({ ...prev, body: event.target.value }))\n }\n multiline\n minRows={3}\n autoFocus\n slotProps={{ htmlInput: { maxLength: BODY_MAX } }}\n />\n {hasOutcome ? (\n <Stack direction=\"row\" spacing={1}>\n <TextField\n size=\"small\"\n label=\"Outcome\"\n placeholder=\"Left a voicemail\"\n value={draft.outcome}\n onChange={(event) =>\n setDraft((prev) => ({ ...prev, outcome: event.target.value }))\n }\n slotProps={{ htmlInput: { maxLength: OUTCOME_MAX } }}\n sx={{ flex: 1 }}\n />\n <TextField\n size=\"small\"\n label=\"Minutes\"\n type=\"number\"\n value={draft.durationMinutes}\n onChange={(event) =>\n setDraft((prev) => ({\n ...prev,\n durationMinutes: event.target.value,\n }))\n }\n error={!durationValid}\n slotProps={{\n htmlInput: { min: 0, max: DURATION_MAX_MINUTES, step: 1 },\n }}\n sx={{ width: 120 }}\n />\n </Stack>\n ) : null}\n {leadOnly ? null : (\n <ActivityLinkFields\n scope={scope}\n picks={picks}\n onChange={(next) => setDraft((prev) => ({ ...prev, picks: next }))}\n link={link}\n deal={deal}\n disabled={saving}\n />\n )}\n </DialogContent>\n <DialogActions>\n <Button onClick={onClose} disabled={saving}>\n {'Cancel'}\n </Button>\n <Button\n variant=\"contained\"\n color=\"primary\"\n disabled={!canSave}\n onClick={handleSave}\n >\n {activity ? 'Save' : 'Log'}\n </Button>\n </DialogActions>\n </Dialog>\n )\n}\nLogActivityDialog.displayName = 'LogActivityDialog'\n\nexport default LogActivityDialog\n"],"names":["Aglyn","useSnackbar","useUser","useUserName","Button","Dialog","DialogActions","DialogContent","DialogTitle","MenuItem","Stack","TextField","addDoc","collection","deleteField","doc","getCountFromServer","query","updateDoc","where","useCallback","useEffect","useRef","useState","crmActivityClientReads","crmVisibleToClause","crmActivityLinkFromPicks","crmActivityPicksEqual","crmActivityPicksFrom","resolveCrmActivityCompany","ActivityLinkFields","CrmSitePicker","BODY_MAX","OUTCOME_MAX","DURATION_MAX_MINUTES","toLocalInput","ms","Number","isFinite","date","Date","pad","value","String","padStart","getFullYear","getMonth","getDate","getHours","getMinutes","fromLocalInput","getTime","DEFAULT_KIND","freshDraft","kind","picks","body","at","now","outcome","durationMinutes","direction","draftFrom","activity","isCrmActivityKind","atMs","crmActivityDirection","LogActivityDialog","props","open","onClose","scope","link","deal","presetKind","firestore","dataScope","hostId","mountedHostId","readTokens","writeTokens","needsSite","consentGroup","org","data","user","authorName","enqueueSnackbar","draft","setDraft","saving","setSaving","openedOn","current","seed","primary","dealPrimaryContactId","dealContactRolesOf","hasOutcome","activityKindHasOutcome","directions","CRM_ACTIVITY_DIRECTIONS","trim","filedOn","leadOnly","Boolean","leadId","contactId","linked","related","duration","durationValid","isInteger","canSave","uid","length","handleSave","named","stamped","groupId","said","slice","relinked","CRM_COLLECTIONS","activities","$id","dealId","companyId","updatedAt","variant","persist","lead","crmActivityCeilingLink","logged","field","id","count","crmActivityLogHasRoom","CRM_ACTIVITY_LOG_FULL_MESSAGE","links","visibleTo","byUid","byName","createdAt","error","console","allowDuplicate","maxWidth","fullWidth","sx","display","flexDirection","gap","disabled","optional","helperText","undefined","spacing","mt","select","size","label","onChange","event","prev","target","minWidth","CRM_ACTIVITY_KINDS","map","CRM_ACTIVITY_KIND_LABELS","slotProps","displayEmpty","inputLabel","shrink","entry","CRM_ACTIVITY_DIRECTION_LABELS","type","flex","multiline","minRows","autoFocus","htmlInput","maxLength","placeholder","min","max","step","width","next","onClick","color","displayName"],"mappings":"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"}
@@ -2,6 +2,7 @@ import { type AglynOrgBilling, type AglynPostalAddress, type ContactLifecycleSta
2
2
  import type { OrgMemberOption } from './use-org-members';
3
3
  /** What the drawer hands back — already normalized where the route would. */
4
4
  export interface NewContactValues {
5
+ /** Normalized, or `''` for a person with no address yet (AGL-4122). */
5
6
  email: string;
6
7
  /** The name as typed, or the first and last names' composition when either was given. */
7
8
  name: string;
@@ -36,8 +37,9 @@ export interface NewContactDrawerProps {
36
37
  /**
37
38
  * The site the contact is being added from — what scopes the company list
38
39
  * and the controller the consent checkbox records a basis for. `null` at
39
- * the organization level (AGL-2630), where the drawer asks which site
40
- * with a picker and holds its submit until one is named.
40
+ * the organization level (AGL-2630), where the drawer asks which site with
41
+ * a picker. An org whose records belong to every site may leave it empty,
42
+ * and one that keeps them apart holds the submit until one is named.
41
43
  */
42
44
  hostId: string | null;
43
45
  /** The org document the shell passed, for the company picker's scope. */
@@ -65,7 +67,9 @@ export interface NewContactDrawerProps {
65
67
  * The two fields a person can mistype in a way the record cannot hold — the
66
68
  * email and the phone number — are checked before the request leaves, with
67
69
  * the same normalizers the route runs, so the refusal lands under the field
68
- * rather than as a sentence from the server. Everything else is the route's
70
+ * rather than as a sentence from the server. Neither is required: a contact
71
+ * needs a name, an email or a number, because the team works a deal with the
72
+ * person in front of them and the address can come later (AGL-4122). Everything else is the route's
69
73
  * to decide: whether the address already belongs to somebody (it merges),
70
74
  * whether the plan's band has room (it refuses, and its sentence is shown
71
75
  * above the form unchanged).
@@ -41,7 +41,9 @@ import { CrmPicklistSelect } from "./picklist-select.js";
41
41
  * The two fields a person can mistype in a way the record cannot hold — the
42
42
  * email and the phone number — are checked before the request leaves, with
43
43
  * the same normalizers the route runs, so the refusal lands under the field
44
- * rather than as a sentence from the server. Everything else is the route's
44
+ * rather than as a sentence from the server. Neither is required: a contact
45
+ * needs a name, an email or a number, because the team works a deal with the
46
+ * person in front of them and the address can come later (AGL-4122). Everything else is the route's
45
47
  * to decide: whether the address already belongs to somebody (it merges),
46
48
  * whether the plan's band has room (it refuses, and its sentence is shown
47
49
  * above the form unchanged).
@@ -71,7 +73,7 @@ import { CrmPicklistSelect } from "./picklist-select.js";
71
73
  * site's group, or at the organization level the picked site's — the one
72
74
  * the route will capture the contact under. `null` until a site is
73
75
  * picked, and the submit waits on it.
74
- */ const { createHostId, createGroup, orgId } = useCrmScope({
76
+ */ const { createHostId, createGroup, createNeedsSite, orgId } = useCrmScope({
75
77
  hostId,
76
78
  org
77
79
  });
@@ -105,6 +107,7 @@ import { CrmPicklistSelect } from "./picklist-select.js";
105
107
  const [marketingConsent, setMarketingConsent] = useState(false);
106
108
  const [emailError, setEmailError] = useState('');
107
109
  const [phoneError, setPhoneError] = useState('');
110
+ const [identityError, setIdentityError] = useState('');
108
111
  /*
109
112
  * A fresh form on every opening. The drawer stays mounted behind the list
110
113
  * between uses, and a person typed and then abandoned must not reappear
@@ -130,24 +133,28 @@ import { CrmPicklistSelect } from "./picklist-select.js";
130
133
  setMarketingConsent(false);
131
134
  setEmailError('');
132
135
  setPhoneError('');
136
+ setIdentityError('');
133
137
  }, [
134
138
  open
135
139
  ]);
136
140
  const handleSubmit = ()=>{
137
141
  var _ref;
138
- const normalizedEmail = normalizeContactEmail(email);
142
+ const trimmedEmail = email.trim();
143
+ const normalizedEmail = trimmedEmail ? normalizeContactEmail(trimmedEmail) : '';
139
144
  const trimmedPhone = phone.trim();
140
145
  const normalizedPhone = trimmedPhone ? normalizePhone(trimmedPhone) : '';
141
- setEmailError(normalizedEmail ? '' : 'Enter a valid email address.');
146
+ setEmailError(trimmedEmail && !normalizedEmail ? 'Enter a valid email address.' : '');
142
147
  setPhoneError(trimmedPhone && !normalizedPhone ? 'Enter it with its country code, like +1 512 555 0107.' : '');
143
148
  const trimmedMobile = mobilePhone.trim();
144
149
  const normalizedMobile = trimmedMobile ? normalizePhone(trimmedMobile) : '';
145
150
  setMobileError(trimmedMobile && !normalizedMobile ? 'Enter it with its country code, like +1 512 555 0107.' : '');
146
- if (!normalizedEmail || trimmedPhone && !normalizedPhone || trimmedMobile && !normalizedMobile) {
151
+ const identified = Boolean(trimmedEmail || trimmedPhone || trimmedMobile || composedName || name.trim());
152
+ setIdentityError(identified ? '' : 'Enter a name, an email or a phone number.');
153
+ if (!identified || trimmedEmail && !normalizedEmail || trimmedPhone && !normalizedPhone || trimmedMobile && !normalizedMobile) {
147
154
  return;
148
155
  }
149
156
  onSubmit({
150
- email: normalizedEmail,
157
+ email: normalizedEmail != null ? normalizedEmail : '',
151
158
  name: composedName || name.trim().slice(0, 120),
152
159
  salutation,
153
160
  firstName: firstName.trim(),
@@ -163,11 +170,12 @@ import { CrmPicklistSelect } from "./picklist-select.js";
163
170
  lifecycleStage,
164
171
  tags: parseContactTags(tags),
165
172
  address: normalizeAddress(address),
166
- marketingConsent,
173
+ marketingConsent: marketingConsent && Boolean(normalizedEmail),
167
174
  disclosedConsentGroup: consentGroup ? consentGroupDisclosureKey(consentGroup) : null
168
175
  });
169
176
  };
170
177
  const disclosure = consentGroup ? consentGroupDisclosure(consentGroup) : null;
178
+ const hasEmail = Boolean(email.trim());
171
179
  // While a first or last name is typed, the name is theirs (AGL-3515).
172
180
  const composedName = composeContactName(firstName, lastName);
173
181
  return /*#__PURE__*/ _jsx(NavigationDrawerComponent, {
@@ -226,10 +234,15 @@ import { CrmPicklistSelect } from "./picklist-select.js";
226
234
  severity: "warning",
227
235
  children: error
228
236
  }) : null,
237
+ identityError ? /*#__PURE__*/ _jsx(Alert, {
238
+ severity: "warning",
239
+ children: identityError
240
+ }) : null,
229
241
  /*#__PURE__*/ _jsx(CrmSitePicker, {
230
242
  hostId: hostId,
243
+ optional: !createNeedsSite,
231
244
  disabled: Boolean(busy),
232
- helperText: "The site that captures this person — it decides which sites may see them and whose marketing consent the checkbox below records."
245
+ helperText: createNeedsSite ? 'The site that captures this person — it decides which sites may see them and whose marketing consent the checkbox below records.' : 'Where they came from, if anywhere. Every site in the organization can see them either way.'
233
246
  }),
234
247
  /*#__PURE__*/ _jsx(TextField, {
235
248
  size: "small",
@@ -238,8 +251,7 @@ import { CrmPicklistSelect } from "./picklist-select.js";
238
251
  value: email,
239
252
  onChange: (event)=>setEmail(event.target.value),
240
253
  error: Boolean(emailError),
241
- helperText: emailError || 'The one field that is required — it is what makes two captures one person.',
242
- required: true,
254
+ helperText: emailError || 'Optional. You can add it later, and an address already on a contact merges into them.',
243
255
  fullWidth: true,
244
256
  autoFocus: true
245
257
  }),
@@ -417,22 +429,22 @@ import { CrmPicklistSelect } from "./picklist-select.js";
417
429
  children: [
418
430
  /*#__PURE__*/ _jsx(FormControlLabel, {
419
431
  control: /*#__PURE__*/ _jsx(Checkbox, {
420
- checked: marketingConsent,
432
+ checked: marketingConsent && hasEmail,
433
+ disabled: !hasEmail,
421
434
  onChange: (event)=>setMarketingConsent(event.target.checked)
422
435
  }),
423
436
  label: "This person opted in to marketing email"
424
437
  }),
425
438
  /*#__PURE__*/ _jsx(FormHelperText, {
426
- children: (disclosure ? `${disclosure} ` : '') + 'Tick it only if they agreed. Adding a contact is never itself consent.'
439
+ children: !hasEmail ? 'Add an email first: there is nowhere to send marketing without one.' : (disclosure ? `${disclosure} ` : '') + (createHostId ? '' : 'With no source site, this records it on every site. ') + 'Tick it only if they agreed. Adding a contact is never itself consent.'
427
440
  })
428
441
  ]
429
442
  }),
430
443
  /*#__PURE__*/ _jsx(Button, {
431
444
  variant: "contained",
432
445
  color: "primary",
433
- // Held until the capturing site is known: the route resolves the
434
- // org from it and stamps the record with it.
435
- disabled: Boolean(busy) || !createHostId,
446
+ // Held only where the site decides who may see the record.
447
+ disabled: Boolean(busy) || createNeedsSite && !createHostId,
436
448
  onClick: handleSubmit,
437
449
  children: busy ? 'Adding…' : 'Add contact'
438
450
  })