@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.
- package/package.json +14 -14
- package/src/lib/components/activity-link-fields.d.ts +34 -0
- package/src/lib/components/activity-link-fields.js +141 -0
- package/src/lib/components/activity-link-fields.js.map +1 -0
- package/src/lib/components/activity-list.d.ts +8 -0
- package/src/lib/components/activity-list.js +4 -2
- package/src/lib/components/activity-list.js.map +1 -1
- package/src/lib/components/activity-queries.d.ts +7 -2
- package/src/lib/components/activity-queries.js +7 -3
- package/src/lib/components/activity-queries.js.map +1 -1
- package/src/lib/components/company-contact-roles-card.d.ts +42 -0
- package/src/lib/components/company-contact-roles-card.js +135 -0
- package/src/lib/components/company-contact-roles-card.js.map +1 -0
- package/src/lib/components/company-detail-page.js +12 -1
- package/src/lib/components/company-detail-page.js.map +1 -1
- package/src/lib/components/company-edit-drawer.js +9 -6
- package/src/lib/components/company-edit-drawer.js.map +1 -1
- package/src/lib/components/company-picker.js +4 -2
- package/src/lib/components/company-picker.js.map +1 -1
- package/src/lib/components/contact-associations-card.js +15 -1
- package/src/lib/components/contact-associations-card.js.map +1 -1
- package/src/lib/components/contact-detail-page.js +13 -14
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-list-columns.js +5 -4
- package/src/lib/components/contact-list-columns.js.map +1 -1
- package/src/lib/components/contact-properties-card.js +41 -12
- package/src/lib/components/contact-properties-card.js.map +1 -1
- package/src/lib/components/contact-roles-editor.d.ts +54 -0
- package/src/lib/components/contact-roles-editor.js +270 -0
- package/src/lib/components/contact-roles-editor.js.map +1 -0
- package/src/lib/components/contacts-section.js +6 -4
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/crm-console-sections.js +1 -1
- package/src/lib/components/crm-console-sections.js.map +1 -1
- package/src/lib/components/crm-record-picker.d.ts +19 -0
- package/src/lib/components/crm-record-picker.js +40 -11
- package/src/lib/components/crm-record-picker.js.map +1 -1
- package/src/lib/components/crm-related-to-picker.d.ts +33 -0
- package/src/lib/components/crm-related-to-picker.js +135 -0
- package/src/lib/components/crm-related-to-picker.js.map +1 -0
- package/src/lib/components/crm-site-picker.d.ts +13 -1
- package/src/lib/components/crm-site-picker.js +15 -6
- package/src/lib/components/crm-site-picker.js.map +1 -1
- package/src/lib/components/crm-transfer-buttons.d.ts +5 -0
- package/src/lib/components/crm-transfer-buttons.js +9 -6
- package/src/lib/components/crm-transfer-buttons.js.map +1 -1
- package/src/lib/components/deal-contact-roles-card.js +21 -239
- package/src/lib/components/deal-contact-roles-card.js.map +1 -1
- package/src/lib/components/deal-detail-page.js +2 -1
- package/src/lib/components/deal-detail-page.js.map +1 -1
- package/src/lib/components/deal-edit-drawer.js +11 -7
- package/src/lib/components/deal-edit-drawer.js.map +1 -1
- package/src/lib/components/deals-section.js +5 -4
- package/src/lib/components/deals-section.js.map +1 -1
- package/src/lib/components/lead-campaigns-card.js +26 -14
- package/src/lib/components/lead-campaigns-card.js.map +1 -1
- package/src/lib/components/log-activity-dialog.d.ts +27 -9
- package/src/lib/components/log-activity-dialog.js +96 -32
- package/src/lib/components/log-activity-dialog.js.map +1 -1
- package/src/lib/components/new-contact-drawer.d.ts +7 -3
- package/src/lib/components/new-contact-drawer.js +27 -15
- package/src/lib/components/new-contact-drawer.js.map +1 -1
- package/src/lib/components/picklist-values-card.js +7 -4
- package/src/lib/components/picklist-values-card.js.map +1 -1
- package/src/lib/components/pipeline-stages-dialog.js +5 -1
- package/src/lib/components/pipeline-stages-dialog.js.map +1 -1
- package/src/lib/components/record-activity-card.d.ts +11 -2
- package/src/lib/components/record-activity-card.js +90 -5
- package/src/lib/components/record-activity-card.js.map +1 -1
- package/src/lib/components/settings-section.d.ts +4 -2
- package/src/lib/components/settings-section.js +7 -5
- package/src/lib/components/settings-section.js.map +1 -1
- package/src/lib/hooks/crm-activity-client-reads.d.ts +8 -0
- package/src/lib/hooks/crm-activity-client-reads.js +50 -0
- package/src/lib/hooks/crm-activity-client-reads.js.map +1 -0
- package/src/lib/hooks/use-campaign-filing-log.d.ts +2 -1
- package/src/lib/hooks/use-campaign-filing-log.js +10 -3
- package/src/lib/hooks/use-campaign-filing-log.js.map +1 -1
- package/src/lib/hooks/use-crm-org-mount.d.ts +8 -6
- package/src/lib/hooks/use-crm-org-mount.js +2 -1
- package/src/lib/hooks/use-crm-org-mount.js.map +1 -1
- package/src/lib/hooks/use-crm-scope.d.ts +17 -5
- package/src/lib/hooks/use-crm-scope.js +12 -5
- package/src/lib/hooks/use-crm-scope.js.map +1 -1
- package/src/lib/model/contact-holder.d.ts +12 -0
- package/src/lib/model/contact-holder.js +20 -1
- package/src/lib/model/contact-holder.js.map +1 -1
- package/src/lib/model/contact-record.d.ts +5 -0
- package/src/lib/model/contact-record.js +7 -1
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/contact-update.d.ts +6 -0
- package/src/lib/model/contact-update.js +1 -0
- package/src/lib/model/contact-update.js.map +1 -1
- package/src/lib/model/crm-activity-link.d.ts +117 -0
- package/src/lib/model/crm-activity-link.js +113 -0
- package/src/lib/model/crm-activity-link.js.map +1 -0
- package/src/lib/model/crm-api.d.ts +4 -1
- package/src/lib/model/crm-api.js +9 -5
- package/src/lib/model/crm-api.js.map +1 -1
- package/src/lib/model/deal-board-model.js +5 -3
- package/src/lib/model/deal-board-model.js.map +1 -1
- package/src/lib/model/deal-contact-roles.d.ts +15 -1
- package/src/lib/model/deal-contact-roles.js +17 -3
- package/src/lib/model/deal-contact-roles.js.map +1 -1
- package/src/lib/model/settle-stage-names.d.ts +25 -0
- package/src/lib/model/settle-stage-names.js +29 -0
- package/src/lib/model/settle-stage-names.js.map +1 -0
- package/src/lib/native-nav.js +1 -1
- package/src/lib/native-nav.js.map +1 -1
- package/src/lib/server/add-contact-by-hand.d.ts +84 -0
- package/src/lib/server/add-contact-by-hand.js +212 -0
- package/src/lib/server/add-contact-by-hand.js.map +1 -0
- package/src/lib/server/api-v1/contacts.js +61 -23
- package/src/lib/server/api-v1/contacts.js.map +1 -1
- package/src/lib/server/api-v1/crm-activities.js +13 -3
- package/src/lib/server/api-v1/crm-activities.js.map +1 -1
- package/src/lib/server/api-v1/crm-pipelines.d.ts +1 -1
- package/src/lib/server/api-v1/crm-pipelines.js +8 -2
- package/src/lib/server/api-v1/crm-pipelines.js.map +1 -1
- package/src/lib/server/api-v1/crm-shared.d.ts +16 -9
- package/src/lib/server/api-v1/crm-shared.js +23 -11
- package/src/lib/server/api-v1/crm-shared.js.map +1 -1
- package/src/lib/server/api-v1/openapi.js +3 -3
- package/src/lib/server/api-v1/openapi.js.map +1 -1
- package/src/lib/server/automation-steps.js +11 -9
- package/src/lib/server/automation-steps.js.map +1 -1
- package/src/lib/server/campaign-filing-activity.js +7 -2
- package/src/lib/server/campaign-filing-activity.js.map +1 -1
- package/src/lib/server/contact-update.d.ts +4 -0
- package/src/lib/server/contact-update.js +71 -10
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/convert-host-lead.js +13 -2
- package/src/lib/server/convert-host-lead.js.map +1 -1
- package/src/lib/server/convert-open-lead.js +8 -1
- package/src/lib/server/convert-open-lead.js.map +1 -1
- package/src/lib/server/crm-activity-company.d.ts +25 -0
- package/src/lib/server/crm-activity-company.js +34 -0
- package/src/lib/server/crm-activity-company.js.map +1 -0
- package/src/lib/server/crm-inbound-email.js +21 -15
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/crm-sharing.js +9 -2
- package/src/lib/server/crm-sharing.js.map +1 -1
- package/src/lib/server/email-send.js +10 -8
- package/src/lib/server/email-send.js.map +1 -1
- package/src/lib/server/merge-contacts.js +6 -2
- package/src/lib/server/merge-contacts.js.map +1 -1
- package/src/lib/server/org-caller.d.ts +8 -0
- package/src/lib/server/org-caller.js +10 -0
- package/src/lib/server/org-caller.js.map +1 -1
- package/src/lib/server/record-timeline.d.ts +3 -1
- package/src/lib/server/record-timeline.js +11 -3
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server/task-reminders-route.d.ts +7 -5
- package/src/lib/server/task-reminders-route.js +82 -15
- package/src/lib/server/task-reminders-route.js.map +1 -1
- package/src/lib/server.js +152 -43
- package/src/lib/server.js.map +1 -1
- package/src/lib/transfer/contacts.js +83 -48
- package/src/lib/transfer/contacts.js.map +1 -1
- package/src/lib/transfer/fields.js +1 -2
- 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
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
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 && !
|
|
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
|
-
|
|
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(
|
|
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
|
-
} : {},
|
|
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
|
-
|
|
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
|
-
|
|
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":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;;AAEA,YAAYA,WAAW,eAAc;AAErC,SAASC,WAAW,QAAQ,8BAA6B;AACzD,SAASC,OAAO,EAAEC,WAAW,QAAQ,iCAAgC;AACrE,SACEC,MAAM,EACNC,MAAM,EACNC,aAAa,EACbC,aAAa,EACbC,WAAW,EACXC,QAAQ,EACRC,KAAK,EACLC,SAAS,QACJ,gBAAe;AACtB,SACEC,MAAM,EACNC,UAAU,EACVC,WAAW,EACXC,GAAG,EACHC,kBAAkB,EAClBC,KAAK,EACLC,SAAS,EACTC,KAAK,QACA,qBAAoB;AAC3B,SAASC,WAAW,EAAEC,SAAS,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,QAAO;AAChE,SAASC,sBAAsB,QAAQ,wCAAoC;AAC3E,SAASC,kBAAkB,QAAQ,4BAAwB;AAC3D,SACEC,wBAAwB,EACxBC,qBAAqB,EACrBC,oBAAoB,EAEpBC,yBAAyB,QACpB,gCAA4B;AACnC,SAASC,kBAAkB,QAAQ,4BAAwB;AAE3D,SAASC,aAAa,QAAQ,uBAAmB;AAEjD,gFAAgF,GAChF,MAAMC,WAAW;AACjB,MAAMC,cAAc;AACpB,MAAMC,uBAAuB,KAAK;AAElC;;;;CAIC,GACD,SAASC,aAAaC,EAAU;IAC9B,IAAI,CAACC,OAAOC,QAAQ,CAACF,KAAK,OAAO;IACjC,MAAMG,OAAO,IAAIC,KAAKJ;IACtB,MAAMK,MAAM,CAACC,QAAkBC,OAAOD,OAAOE,QAAQ,CAAC,GAAG;IACzD,OACE,GAAGL,KAAKM,WAAW,GAAG,CAAC,EAAEJ,IAAIF,KAAKO,QAAQ,KAAK,GAAG,CAAC,CAAC,GACpD,GAAGL,IAAIF,KAAKQ,OAAO,IAAI,CAAC,EAAEN,IAAIF,KAAKS,QAAQ,IAAI,CAAC,EAAEP,IAAIF,KAAKU,UAAU,KAAK;AAE9E;AAEA,2EAA2E,GAC3E,SAASC,eAAeR,KAAa;IACnC,IAAI,CAACA,OAAO,OAAO;IACnB,MAAMN,KAAK,IAAII,KAAKE,OAAOS,OAAO;IAClC,OAAOd,OAAOC,QAAQ,CAACF,MAAMA,KAAK;AACpC;AAoBA,iEAAiE,GACjE,MAAMgB,eAAgC;AAEtC,MAAMC,aAAa,CACjBC,OAAwBF,YAAY,EACpCG,QAA0B3B,qBAAqB,CAAC,EAAE,GACvC,CAAA;QACX0B;QACAE,MAAM;QACNC,IAAItB,aAAaK,KAAKkB,GAAG;QACzBC,SAAS;QACTC,iBAAiB;QACjB,mDAAmD;QACnDC,WAAW;QACXN;IACF,CAAA;AAEA,MAAMO,YAAY,CAACC;QAEXA,gBAEGA,mBAKE/D;WAT2C;QACtDsD,MAAMtD,MAAMgE,iBAAiB,CAACD,SAAST,IAAI,IAAIS,SAAST,IAAI,GAAG;QAC/DE,IAAI,GAAEO,iBAAAA,SAASP,IAAI,YAAbO,iBAAiB;QACvBN,IAAItB,aAAa4B,SAASE,IAAI;QAC9BN,OAAO,GAAEI,oBAAAA,SAASJ,OAAO,YAAhBI,oBAAoB;QAC7BH,iBACE,OAAOG,SAASH,eAAe,KAAK,WAChCjB,OAAOoB,SAASH,eAAe,IAC/B;QACNC,SAAS,GAAE7D,8BAAAA,MAAMkE,oBAAoB,CAACH,SAAST,IAAI,EAAES,SAASF,SAAS,aAA5D7D,8BAAiE;QAC5EuD,OAAO3B,qBAAqBmC;IAC9B;;AAiCA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqCC,GACD,OAAO,SAASI,kBAAkBC,KAA6B;QAkC1CpE;IAjCnB,MAAM,EAAEqE,IAAI,EAAEC,OAAO,EAAEC,KAAK,EAAEC,IAAI,EAAEC,IAAI,EAAEV,QAAQ,EAAET,MAAMoB,UAAU,EAAE,GAAGN;IACzE,MAAM,EAAEO,SAAS,EAAEC,SAAS,EAAEC,MAAM,EAAEC,aAAa,EAAEC,UAAU,EAAEC,WAAW,EAAEC,SAAS,EAAE,GACvFV;IACF,MAAM,EAAEW,YAAY,EAAEC,GAAG,EAAE,GAAGZ;IAC9B,MAAM,EAAEa,MAAMC,IAAI,EAAE,GAAGnF;IACvB,MAAMoF,aAAanF;IACnB,MAAM,EAAEoF,eAAe,EAAE,GAAGtF;IAC5B,MAAM,CAACuF,OAAOC,SAAS,GAAGlE,SAAgB8B;IAC1C,MAAM,CAACqC,QAAQC,UAAU,GAAGpE,SAAS;IAErC;;;;;;GAMC,GACD,MAAMqE,WAAWtE,OAAO;QAAEkD;QAAMC;IAAK;IACrCpD,UAAU;QACRuE,SAASC,OAAO,GAAG;YAAErB;YAAMC;QAAK;IAClC,GAAG;QAACD;QAAMC;KAAK;IACfpD,UAAU;QACR,IAAI,CAACgD,MAAM;QACX,MAAMyB,OAAOF,SAASC,OAAO;QAC7B,MAAME,UAAUD,KAAKrB,IAAI,GAAGzE,MAAMgG,oBAAoB,CAAChG,MAAMiG,kBAAkB,CAACH,KAAKrB,IAAI,KAAK;QAC9FgB,SACE1B,WACID,UAAUC,YACVV,WAAWqB,YAAY9C,qBAAqBkE,KAAKtB,IAAI,EAAEuB;IAE/D,GAAG;QAAC1B;QAAMN;QAAUW;KAAW;IAE/B,MAAMwB,aAAalG,MAAMmG,sBAAsB,CAACX,MAAMlC,IAAI;IAC1D,MAAM8C,cAAapG,4CAAAA,MAAMqG,uBAAuB,CAACb,MAAMlC,IAAI,CAAC,YAAzCtD,4CAA6C,EAAE;IAClE,MAAM6D,YAAY7D,MAAMkE,oBAAoB,CAACsB,MAAMlC,IAAI,EAAEkC,MAAM3B,SAAS;IACxE,MAAMI,OAAOf,eAAesC,MAAM/B,EAAE;IACpC,MAAMD,OAAOgC,MAAMhC,IAAI,CAAC8C,IAAI;IAC5B;;;;GAIC,GACD,MAAMC,UAAUxC,mBAAAA,WAAYS;IAC5B,MAAMgC,WAAWC,QAAQF,QAAQG,MAAM,KAAK,CAACH,QAAQI,SAAS;IAC9D,MAAM,EAAEpD,KAAK,EAAE,GAAGiC;IAClB,4EAA4E;IAC5E,MAAMoB,SAASJ,YAAYC,QAAQlD,MAAMoD,SAAS,IAAIpD,MAAMsD,OAAO;IACnE,MAAMC,WAAWtB,MAAM5B,eAAe,CAAC0C,IAAI,KACvCjE,OAAOmD,MAAM5B,eAAe,IAC5B;IACJ,MAAMmD,gBACJD,aAAa,QACZzE,OAAO2E,SAAS,CAACF,aAChBA,YAAY,KACZA,YAAY5E;IAChB,MAAM+E,UACJR,QAAQ7B,cACR6B,QAAQpB,wBAAAA,KAAM6B,GAAG,KACjB,yEAAyE;IACzE,sEAAsE;IACtE,sEAAsE;IACtE,4CAA4C;IAC3CT,CAAAA,QAAQ1C,aAAaiB,YAAYmC,MAAM,GAAG,CAAA,KAC3C3D,KAAK2D,MAAM,GAAG,KACdlD,SAAS,QACT8C,iBACAH,UACA,CAAClB;IAEH,MAAM0B,aAAahG,YAAY;QAC7B,mEAAmE;QACnE,2BAA2B;QAC3B,IAAI,CAACwD,aAAa,EAACS,wBAAAA,KAAM6B,GAAG,KAAI,CAAC1D,QAAQS,SAAS,QAAQ,CAAC8C,iBAAiB,CAACH,QAAQ;YACnF;QACF;QACA,IAAI,CAAC7C,YAAY,CAACiB,YAAYmC,MAAM,EAAE;QACtCxB,UAAU;QACV,IAAI;YACF,MAAM0B,QAAQ3F,yBAAyB6B,OAAOgD,QAAQG,MAAM;YAC5D,MAAMY,UAAU,IACdzF,0BACEwF,OACA;oBAAEE,OAAO,EAAErC,gCAAAA,aAAcqC,OAAO;oBAAEpC;gBAAI,GACtC3D,uBAAuBmD,WAAWC,SAAS,CAAC,EAAE;YAElD,MAAM4C,OAAO;gBACXlE,MAAMkC,MAAMlC,IAAI;gBAChBE,MAAMA,KAAKiE,KAAK,CAAC,GAAGzF;gBACpBiC;YACF;YACA,MAAMN,UAAUuC,aAAaV,MAAM7B,OAAO,CAAC2C,IAAI,GAAGmB,KAAK,CAAC,GAAGxF,eAAe;YAC1E,MAAM2B,kBAAkBsC,cAAcY,aAAa,OAAOA,WAAW;YACrE,IAAI/C,UAAU;oBAkBS2D,qBACHA,kBACGA;gBAnBrB,MAAMA,WAAW/F,sBAAsB4B,OAAO3B,qBAAqBmC,aAC/D,OACA,MAAMuD;gBACV,MAAMpG,UACJH,IACE4D,WACAC,SAAS,CAAC,EAAE,EACZA,SAAS,CAAC,EAAE,EACZ5E,MAAM2H,eAAe,CAACC,UAAU,EAChC7D,SAAS8D,GAAG,GAEd,aACKL,MAGCE,WACA;oBACEf,SAAS,GAAEe,sBAAAA,SAASf,SAAS,YAAlBe,sBAAsB5G;oBACjCgH,MAAM,GAAEJ,mBAAAA,SAASI,MAAM,YAAfJ,mBAAmB5G;oBAC3BiH,SAAS,GAAEL,sBAAAA,SAASK,SAAS,YAAlBL,sBAAsB5G;gBACnC,IACA,CAAC;oBACL,+DAA+D;oBAC/D,8DAA8D;oBAC9D6C,SAASA,UAAUA,UAAU7C;oBAC7B+C,SAAS,EAAEA,oBAAAA,YAAa/C;oBACxB8C,iBACEA,oBAAoB,OAAOA,kBAAkB9C;oBAC/CkH,WAAW,IAAIxF;;gBAGnB+C,gBAAgB,oBAAoB;oBAAE0C,SAAS;oBAAWC,SAAS;gBAAM;YAC3E,OAAO;gBACL;;;;;;;SAOC,GACD,MAAMC,OAAOnI,MAAMoI,sBAAsB,CAACf;gBAC1C,IAAIc,MAAM;oBACR,MAAME,SAAS,AACb,CAAA,MAAMrH,mBACJC,MACEJ,WACE8D,WACAC,SAAS,CAAC,EAAE,EACZA,SAAS,CAAC,EAAE,EACZ5E,MAAM2H,eAAe,CAACC,UAAU,MAE/BnG,mBAAmBsD,aACtB5D,MAAMgH,KAAKG,KAAK,EAAE,MAAMH,KAAKI,EAAE,GAEnC,EACAnD,IAAI,GAAGoD,KAAK;oBACd,IAAI,CAACxI,MAAMyI,qBAAqB,CAACJ,SAAS;wBACxC9C,gBAAgBvF,MAAM0I,6BAA6B,EAAE;4BACnDT,SAAS;4BACTC,SAAS;wBACX;wBACA;oBACF;gBACF;gBACA,MAAMS,QAAQ,MAAMrB;gBACpB,MAAM1G,OACJC,WACE8D,WACAC,SAAS,CAAC,EAAE,EACZA,SAAS,CAAC,EAAE,EACZ5E,MAAM2H,eAAe,CAACC,UAAU,GAElC,aACKJ,MACC7D,UAAU;oBAAEA;gBAAQ,IAAI,CAAC,GACzBC,oBAAoB,OAAO;oBAAEA;gBAAgB,IAAI,CAAC,GAClDC,YAAY;oBAAEA;gBAAU,IAAI,CAAC,GAG9B8E;oBACHC,WAAW5D;oBACXH;oBACAgE,OAAOxD,KAAK6B,GAAG;mBAIX5B,aAAa;oBAAEwD,QAAQxD;gBAAW,IAAI,CAAC;oBAC3CyD,WAAW,IAAIvG;oBACfwF,WAAW,IAAIxF;;gBAGnB+C,gBAAgB,mBAAmB;oBAAE0C,SAAS;oBAAWC,SAAS;gBAAM;YAC1E;YACA5D;QACF,EAAE,OAAO0E,OAAO;YACdC,QAAQD,KAAK,CAACA;YACdzD,gBAAgB,yBAAyB;gBACvC0C,SAAS;gBACTiB,gBAAgB;YAClB;QACF,SAAU;YACRvD,UAAU;QACZ;IACF,GAAG;QACDf;QACAS;QACA7B;QACAS;QACA8C;QACAvB,MAAMlC,IAAI;QACVkC,MAAM7B,OAAO;QACbE;QACAqC;QACAY;QACA/C;QACAY;QACAY;QACAqB;QACArD;QACAgD,QAAQG,MAAM;QACdxB;QACAC;QACAJ;QACAC;QACAH;QACAS;QACAhB;KACD;IAED,qBACE,MAACjE;QAAOgE,MAAMA;QAAMC,SAASA;QAAS6E,UAAS;QAAKC,SAAS;;0BAC3D,KAAC5I;0BAAauD,WAAW,kBAAkB;;0BAC3C,MAACxD;gBAAc8I,IAAI;oBAAEC,SAAS;oBAAQC,eAAe;oBAAUC,KAAK;gBAAE;;oBAEnEzF,WAAW,qBACV,KAAChC;wBACC8C,QAAQC;wBACR2E,UAAU/D;wBACVgE,UAAUzE,cAAc;wBACxB0E,YACE1E,cAAc,QACV2E,YACA;;kCAIV,MAAClJ;wBAAMmD,WAAU;wBAAMgG,SAAS;wBAAGR,IAAI;4BAAES,IAAI;wBAAE;;0CAC7C,KAACnJ;gCACCoJ,MAAM;gCACNC,MAAK;gCACLC,OAAM;gCACNvH,OAAO8C,MAAMlC,IAAI;gCACjB4G,UAAU,CAACC,QACT1E,SAAS,CAAC2E,OAAU,aACfA;4CACH9G,MAAMtD,MAAMgE,iBAAiB,CAACmG,MAAME,MAAM,CAAC3H,KAAK,IAC5CyH,MAAME,MAAM,CAAC3H,KAAK,GAClB0H,KAAK9G,IAAI;;gCAGjB+F,IAAI;oCAAEiB,UAAU;gCAAI;0CAEnBtK,MAAMuK,kBAAkB,CAACC,GAAG,CAAC,CAAClH,qBAC7B,KAAC7C;wCAAoBiC,OAAOY;kDACzBtD,MAAMyK,wBAAwB,CAACnH,KAAK;uCADxBA;;4BAKlB8C,WAAWe,MAAM,iBAChB,MAACxG;gCACCoJ,MAAM;gCACNC,MAAK;gCACLC,OAAM;gCACNvH,KAAK,EAAEmB,oBAAAA,YAAa;gCACpBqG,UAAU,CAACC,QACT1E,SAAS,CAAC2E,OAAU,aAAKA;4CAAMvG,WAAWlB,OAAOwH,MAAME,MAAM,CAAC3H,KAAK;;gCAErEgI,WAAW;oCAAEX,QAAQ;wCAAEY,cAAc;oCAAK;oCAAGC,YAAY;wCAAEC,QAAQ;oCAAK;gCAAE;gCAC1ExB,IAAI;oCAAEiB,UAAU;gCAAI;;kDAEpB,KAAC7J;wCAASiC,OAAM;kDAAI;;oCACnB0D,WAAWoE,GAAG,CAAC,CAACM,sBACf,KAACrK;4CAAqBiC,OAAOoI;sDAC1B9K,MAAM+K,6BAA6B,CAACD,MAAM;2CAD9BA;;iCAKjB;0CACJ,KAACnK;gCACCqJ,MAAK;gCACLC,OAAM;gCACNe,MAAK;gCACLtI,OAAO8C,MAAM/B,EAAE;gCACfyG,UAAU,CAACC,QACT1E,SAAS,CAAC2E,OAAU,aAAKA;4CAAM3G,IAAI0G,MAAME,MAAM,CAAC3H,KAAK;;gCAEvDsG,OAAOxD,MAAM/B,EAAE,CAAC0D,MAAM,GAAG,KAAKlD,SAAS;gCACvCyG,WAAW;oCAAEE,YAAY;wCAAEC,QAAQ;oCAAK;gCAAE;gCAC1CxB,IAAI;oCAAE4B,MAAM;gCAAE;;;;kCAGlB,KAACtK;wBACCqJ,MAAK;wBACLC,OAAM;wBACNvH,OAAO8C,MAAMhC,IAAI;wBACjB0G,UAAU,CAACC,QACT1E,SAAS,CAAC2E,OAAU,aAAKA;oCAAM5G,MAAM2G,MAAME,MAAM,CAAC3H,KAAK;;wBAEzDwI,SAAS;wBACTC,SAAS;wBACTC,SAAS;wBACTV,WAAW;4BAAEW,WAAW;gCAAEC,WAAWtJ;4BAAS;wBAAE;;oBAEjDkE,2BACC,MAACxF;wBAAMmD,WAAU;wBAAMgG,SAAS;;0CAC9B,KAAClJ;gCACCqJ,MAAK;gCACLC,OAAM;gCACNsB,aAAY;gCACZ7I,OAAO8C,MAAM7B,OAAO;gCACpBuG,UAAU,CAACC,QACT1E,SAAS,CAAC2E,OAAU,aAAKA;4CAAMzG,SAASwG,MAAME,MAAM,CAAC3H,KAAK;;gCAE5DgI,WAAW;oCAAEW,WAAW;wCAAEC,WAAWrJ;oCAAY;gCAAE;gCACnDoH,IAAI;oCAAE4B,MAAM;gCAAE;;0CAEhB,KAACtK;gCACCqJ,MAAK;gCACLC,OAAM;gCACNe,MAAK;gCACLtI,OAAO8C,MAAM5B,eAAe;gCAC5BsG,UAAU,CAACC,QACT1E,SAAS,CAAC2E,OAAU,aACfA;4CACHxG,iBAAiBuG,MAAME,MAAM,CAAC3H,KAAK;;gCAGvCsG,OAAO,CAACjC;gCACR2D,WAAW;oCACTW,WAAW;wCAAEG,KAAK;wCAAGC,KAAKvJ;wCAAsBwJ,MAAM;oCAAE;gCAC1D;gCACArC,IAAI;oCAAEsC,OAAO;gCAAI;;;yBAGnB;oBACHnF,WAAW,qBACV,KAAC1E;wBACCyC,OAAOA;wBACPhB,OAAOA;wBACP2G,UAAU,CAAC0B,OAASnG,SAAS,CAAC2E,OAAU,aAAKA;oCAAM7G,OAAOqI;;wBAC1DpH,MAAMA;wBACNC,MAAMA;wBACNgF,UAAU/D;;;;0BAIhB,MAACpF;;kCACC,KAACF;wBAAOyL,SAASvH;wBAASmF,UAAU/D;kCACjC;;kCAEH,KAACtF;wBACC6H,SAAQ;wBACR6D,OAAM;wBACNrC,UAAU,CAACxC;wBACX4E,SAASzE;kCAERrD,WAAW,SAAS;;;;;;AAK/B;AACAI,kBAAkB4H,WAAW,GAAG;AAEhC,eAAe5H,kBAAiB"}
|
|
@@ -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
|
-
*
|
|
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.
|
|
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.
|
|
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
|
|
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 ? '
|
|
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
|
-
|
|
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:
|
|
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 || '
|
|
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
|
|
434
|
-
|
|
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
|
})
|