@aglyn/aglyn 1.0.0-beta.219 → 1.0.0-beta.221
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 +12 -12
- package/src/lib/app-utils/actions.d.ts +1 -59
- package/src/lib/app-utils/actions.js +7 -72
- package/src/lib/app-utils/actions.js.map +1 -1
- package/src/lib/app-utils/consent-groups.d.ts +1 -1
- package/src/lib/app-utils/consent-groups.js.map +1 -1
- package/src/lib/app-utils/console-routes.d.ts +0 -36
- package/src/lib/app-utils/console-routes.js +2 -37
- package/src/lib/app-utils/console-routes.js.map +1 -1
- package/src/lib/app-utils/contacts.d.ts +1 -1
- package/src/lib/app-utils/contacts.js.map +1 -1
- package/src/lib/app-utils/container-membership.d.ts +93 -0
- package/src/lib/app-utils/container-membership.js +165 -0
- package/src/lib/app-utils/container-membership.js.map +1 -0
- package/src/lib/app-utils/crm-task-reminders.d.ts +23 -6
- package/src/lib/app-utils/crm-task-reminders.js +32 -11
- package/src/lib/app-utils/crm-task-reminders.js.map +1 -1
- package/src/lib/app-utils/crm.d.ts +24 -4
- package/src/lib/app-utils/crm.js +23 -0
- package/src/lib/app-utils/crm.js.map +1 -1
- package/src/lib/app-utils/csv-upload.d.ts +1 -1
- package/src/lib/app-utils/csv-upload.js +1 -1
- package/src/lib/app-utils/csv-upload.js.map +1 -1
- package/src/lib/app-utils/{dataset-csv.d.ts → csv.d.ts} +10 -43
- package/src/lib/app-utils/{dataset-csv.js → csv.js} +10 -81
- package/src/lib/app-utils/csv.js.map +1 -0
- package/src/lib/app-utils/datasets.d.ts +0 -19
- package/src/lib/app-utils/datasets.js +0 -13
- package/src/lib/app-utils/datasets.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +4 -4
- package/src/lib/app-utils/docs-help.generated.js +5 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +71 -36
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/dynamic-list-rule.js +3 -3
- package/src/lib/app-utils/dynamic-list-rule.js.map +1 -1
- package/src/lib/app-utils/email-text.d.ts +80 -0
- package/src/lib/app-utils/email-text.js +189 -0
- package/src/lib/app-utils/email-text.js.map +1 -0
- package/src/lib/app-utils/{list-assignment-policy.d.ts → enrollment-basis.d.ts} +4 -0
- package/src/lib/app-utils/{list-assignment-policy.js → enrollment-basis.js} +5 -1
- package/src/lib/app-utils/enrollment-basis.js.map +1 -0
- package/src/lib/app-utils/expand-repeatables.js +4 -4
- package/src/lib/app-utils/expand-repeatables.js.map +1 -1
- package/src/lib/app-utils/forms.js +2 -2
- package/src/lib/app-utils/forms.js.map +1 -1
- package/src/lib/app-utils/health-report.d.ts +2 -3
- package/src/lib/app-utils/health-report.js +30 -6
- package/src/lib/app-utils/health-report.js.map +1 -1
- package/src/lib/app-utils/inbound-mail-domain.d.ts +27 -0
- package/src/lib/app-utils/inbound-mail-domain.js +31 -0
- package/src/lib/app-utils/inbound-mail-domain.js.map +1 -0
- package/src/lib/app-utils/leaving-notice.d.ts +96 -0
- package/src/lib/app-utils/leaving-notice.js +157 -0
- package/src/lib/app-utils/leaving-notice.js.map +1 -0
- package/src/lib/app-utils/lockdown.d.ts +11 -0
- package/src/lib/app-utils/lockdown.js +33 -3
- package/src/lib/app-utils/lockdown.js.map +1 -1
- package/src/lib/app-utils/member-email-aliases.d.ts +25 -0
- package/src/lib/app-utils/member-email-aliases.js +29 -0
- package/src/lib/app-utils/member-email-aliases.js.map +1 -1
- package/src/lib/app-utils/notifications.d.ts +69 -0
- package/src/lib/app-utils/notifications.js +119 -0
- package/src/lib/app-utils/notifications.js.map +1 -1
- package/src/lib/app-utils/operator-alerts.d.ts +15 -1
- package/src/lib/app-utils/operator-alerts.js +64 -10
- package/src/lib/app-utils/operator-alerts.js.map +1 -1
- package/src/lib/app-utils/plan-entitlements.d.ts +164 -178
- package/src/lib/app-utils/plan-entitlements.js +200 -224
- package/src/lib/app-utils/plan-entitlements.js.map +1 -1
- package/src/lib/app-utils/platform-marketing-consent.d.ts +56 -2
- package/src/lib/app-utils/platform-marketing-consent.js +6 -5
- package/src/lib/app-utils/platform-marketing-consent.js.map +1 -1
- package/src/lib/app-utils/plugin-starter-templates.generated.d.ts +12 -0
- package/src/lib/app-utils/plugin-starter-templates.generated.js +711 -0
- package/src/lib/app-utils/plugin-starter-templates.generated.js.map +1 -0
- package/src/lib/app-utils/repeat-query.d.ts +56 -0
- package/src/lib/app-utils/{dataset-query.js → repeat-query.js} +16 -16
- package/src/lib/app-utils/repeat-query.js.map +1 -0
- package/src/lib/app-utils/screen-link-context-value.d.ts +7 -0
- package/src/lib/app-utils/screen-link-context-value.js.map +1 -1
- package/src/lib/app-utils/screen-link-context.js +14 -2
- package/src/lib/app-utils/screen-link-context.js.map +1 -1
- package/src/lib/app-utils/screen-route.d.ts +3 -0
- package/src/lib/app-utils/screen-route.js +6 -1
- package/src/lib/app-utils/screen-route.js.map +1 -1
- package/src/lib/app-utils/server.d.ts +7 -9
- package/src/lib/app-utils/server.js +18 -26
- package/src/lib/app-utils/server.js.map +1 -1
- package/src/lib/app-utils/starter-template-nodes.d.ts +137 -0
- package/src/lib/app-utils/starter-template-nodes.js +129 -0
- package/src/lib/app-utils/starter-template-nodes.js.map +1 -0
- package/src/lib/app-utils/starter-templates.d.ts +11 -34
- package/src/lib/app-utils/starter-templates.js +19 -230
- package/src/lib/app-utils/starter-templates.js.map +1 -1
- package/src/lib/app-utils/{email-topics.d.ts → subscription-topics.d.ts} +64 -48
- package/src/lib/app-utils/{email-topics.js → subscription-topics.js} +39 -109
- package/src/lib/app-utils/subscription-topics.js.map +1 -0
- package/src/lib/app-utils/usage-budget.d.ts +73 -188
- package/src/lib/app-utils/usage-budget.js +58 -194
- package/src/lib/app-utils/usage-budget.js.map +1 -1
- package/src/lib/app-utils/utm-attribution.d.ts +1 -1
- package/src/lib/app-utils/utm-attribution.js +1 -1
- package/src/lib/app-utils/utm-attribution.js.map +1 -1
- package/src/lib/app-utils/{campaign-touch.d.ts → utm-touch.d.ts} +21 -13
- package/src/lib/app-utils/{campaign-touch.js → utm-touch.js} +30 -22
- package/src/lib/app-utils/utm-touch.js.map +1 -0
- package/src/lib/app-utils/variables.d.ts +9 -9
- package/src/lib/app-utils/variables.js.map +1 -1
- package/src/lib/app-utils/visitor-record-ceiling.d.ts +32 -0
- package/src/lib/app-utils/visitor-record-ceiling.js +39 -0
- package/src/lib/app-utils/visitor-record-ceiling.js.map +1 -1
- package/src/lib/foundation/definitions/org-billing.types.d.ts +2 -2
- package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
- package/src/lib/plugin-manager/feature-plugins.d.ts +80 -79
- package/src/lib/plugin-manager/feature-plugins.js +22 -35
- package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.d.ts +18 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +62 -2
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
- package/src/lib/plugin-manager/index.d.ts +0 -1
- package/src/lib/plugin-manager/index.js +0 -1
- package/src/lib/plugin-manager/index.js.map +1 -1
- package/src/lib/plugin-manager/plugin-contact-capture.d.ts +9 -4
- package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
- package/src/lib/plugin-manager/plugin-containers.d.ts +68 -0
- package/src/lib/plugin-manager/plugin-containers.generated.d.ts +10 -0
- package/src/lib/plugin-manager/plugin-containers.generated.js +20 -0
- package/src/lib/plugin-manager/plugin-containers.generated.js.map +1 -0
- package/src/lib/plugin-manager/plugin-containers.js +27 -0
- package/src/lib/plugin-manager/plugin-containers.js.map +1 -0
- package/src/lib/plugin-manager/plugin-entitlements.d.ts +8 -0
- package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
- package/src/lib/plugin-manager/plugin-membership-detach.js +1 -1
- package/src/lib/plugin-manager/plugin-membership-detach.js.map +1 -1
- package/src/lib/plugin-manager/plugin-record-email-state.d.ts +69 -0
- package/src/lib/plugin-manager/plugin-record-email-state.js +32 -0
- package/src/lib/plugin-manager/plugin-record-email-state.js.map +1 -1
- package/src/lib/plugin-manager/plugin-record-lists.d.ts +115 -0
- package/src/lib/plugin-manager/plugin-record-lists.js +75 -0
- package/src/lib/plugin-manager/plugin-record-lists.js.map +1 -0
- package/src/lib/plugin-manager/plugin-record-timeline.d.ts +42 -8
- package/src/lib/plugin-manager/plugin-record-timeline.js.map +1 -1
- package/src/lib/plugin-manager/plugin-server-steps.d.ts +170 -0
- package/src/lib/plugin-manager/plugin-server-steps.js +116 -0
- package/src/lib/plugin-manager/plugin-server-steps.js.map +1 -0
- package/src/lib/plugin-manager/plugin-site-beacons.d.ts +71 -0
- package/src/lib/plugin-manager/plugin-site-beacons.js +54 -0
- package/src/lib/plugin-manager/plugin-site-beacons.js.map +1 -0
- package/src/lib/plugin-manager/plugin-subscription-topics.generated.d.ts +11 -0
- package/src/lib/plugin-manager/plugin-subscription-topics.generated.js +41 -0
- package/src/lib/plugin-manager/plugin-subscription-topics.generated.js.map +1 -0
- package/src/lib/plugin-manager/plugin-usage-axes.d.ts +212 -0
- package/src/lib/plugin-manager/plugin-usage-axes.js +82 -3
- package/src/lib/plugin-manager/plugin-usage-axes.js.map +1 -1
- package/src/lib/plugin-manager/plugin-usage-meters.d.ts +108 -0
- package/src/lib/plugin-manager/plugin-usage-meters.js +105 -0
- package/src/lib/plugin-manager/plugin-usage-meters.js.map +1 -0
- package/src/lib/plugin-manager/usage-alert-contributors.d.ts +76 -6
- package/src/lib/plugin-manager/usage-alert-contributors.js +23 -10
- package/src/lib/plugin-manager/usage-alert-contributors.js.map +1 -1
- package/src/lib/app-utils/assist-credits.d.ts +0 -482
- package/src/lib/app-utils/assist-credits.js +0 -510
- package/src/lib/app-utils/assist-credits.js.map +0 -1
- package/src/lib/app-utils/campaign-membership.d.ts +0 -121
- package/src/lib/app-utils/campaign-membership.js +0 -189
- package/src/lib/app-utils/campaign-membership.js.map +0 -1
- package/src/lib/app-utils/campaign-touch.js.map +0 -1
- package/src/lib/app-utils/console-record-links.d.ts +0 -84
- package/src/lib/app-utils/console-record-links.js +0 -76
- package/src/lib/app-utils/console-record-links.js.map +0 -1
- package/src/lib/app-utils/crm-booking.d.ts +0 -75
- package/src/lib/app-utils/crm-booking.js +0 -133
- package/src/lib/app-utils/crm-booking.js.map +0 -1
- package/src/lib/app-utils/crm-inbound.d.ts +0 -245
- package/src/lib/app-utils/crm-inbound.js +0 -407
- package/src/lib/app-utils/crm-inbound.js.map +0 -1
- package/src/lib/app-utils/crm-reports.d.ts +0 -479
- package/src/lib/app-utils/crm-reports.js +0 -680
- package/src/lib/app-utils/crm-reports.js.map +0 -1
- package/src/lib/app-utils/dataset-csv.js.map +0 -1
- package/src/lib/app-utils/dataset-query.d.ts +0 -57
- package/src/lib/app-utils/dataset-query.js.map +0 -1
- package/src/lib/app-utils/email-topics.js.map +0 -1
- package/src/lib/app-utils/list-assignment-policy.js.map +0 -1
- package/src/lib/plugin-manager/plugin-metered-lines.d.ts +0 -97
- package/src/lib/plugin-manager/plugin-metered-lines.js +0 -123
- package/src/lib/plugin-manager/plugin-metered-lines.js.map +0 -1
|
@@ -1,680 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @license
|
|
3
|
-
* Copyright 2026 Aglyn LLC
|
|
4
|
-
*
|
|
5
|
-
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
-
* you may not use this file except in compliance with the License.
|
|
7
|
-
* You may obtain a copy of the License at
|
|
8
|
-
*
|
|
9
|
-
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
-
*
|
|
11
|
-
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
-
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
-
* See the License for the specific language governing permissions and
|
|
15
|
-
* limitations under the License.
|
|
16
|
-
*/ /**
|
|
17
|
-
* CRM reporting arithmetic (AGL-2604).
|
|
18
|
-
*
|
|
19
|
-
* The Reports section and the dashboard glance card read Firestore
|
|
20
|
-
* aggregates and bounded windows and then have to turn them into a period, a
|
|
21
|
-
* set of weekly bars, a funnel and a pipeline total. Every one of those is a
|
|
22
|
-
* decision about numbers that a card could get subtly wrong in its own JSX —
|
|
23
|
-
* a bucket boundary that is closed on both ends counts a midnight contact
|
|
24
|
-
* twice, a funnel that divides by the step before reads 100% where nobody
|
|
25
|
-
* arrived, a forecast that weights a closed deal forecasts a sale twice. So
|
|
26
|
-
* they live here, pure and specified, and the cards only draw.
|
|
27
|
-
*
|
|
28
|
-
* Pure data module: no Firestore, no React, no clock — every function that
|
|
29
|
-
* needs "now" is handed it.
|
|
30
|
-
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
31
|
-
import { CONTACT_LIFECYCLE_STAGES, CONTACT_LIFECYCLE_STAGE_LABELS, CRM_ACTIVITY_KINDS, CRM_LEAD_STATUSES, crmLeadStatus, dealStageById, isCrmActivityKind, isPipelineArchived, weightedDealAmountCents } from "./crm.js";
|
|
32
|
-
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
33
|
-
const WEEK_MS = 7 * DAY_MS;
|
|
34
|
-
/**
|
|
35
|
-
* The periods a report can be read over.
|
|
36
|
-
*
|
|
37
|
-
* A fixed list rather than a free date range because every period here is
|
|
38
|
-
* also a READ COST: the contact trend runs one count aggregate per week of
|
|
39
|
-
* the period, and the closed-deal chart reads the deals closed within it. A
|
|
40
|
-
* picker that offered "all time" would offer a read nobody has sized.
|
|
41
|
-
*/ export const CRM_REPORT_PERIODS = [
|
|
42
|
-
'7d',
|
|
43
|
-
'30d',
|
|
44
|
-
'90d',
|
|
45
|
-
'month'
|
|
46
|
-
];
|
|
47
|
-
export const CRM_REPORT_PERIOD_LABELS = {
|
|
48
|
-
'7d': 'Last 7 days',
|
|
49
|
-
'30d': 'Last 30 days',
|
|
50
|
-
'90d': 'Last 90 days',
|
|
51
|
-
month: 'This month'
|
|
52
|
-
};
|
|
53
|
-
/**
|
|
54
|
-
* The range a period names at a given moment.
|
|
55
|
-
*
|
|
56
|
-
* `to` is the moment itself, so a period is always "up to now" and a report
|
|
57
|
-
* read twice in one sitting shows the same figures with the newer minutes
|
|
58
|
-
* added. "This month" starts on the first of the LOCAL month, because that
|
|
59
|
-
* is the month the reader sees on their calendar; the day periods count back
|
|
60
|
-
* from the moment rather than from midnight, so "last 7 days" is a full
|
|
61
|
-
* seven days rather than six and a fraction.
|
|
62
|
-
*/ export function crmReportRange(period, nowMs) {
|
|
63
|
-
if (period === 'month') {
|
|
64
|
-
const now = new Date(nowMs);
|
|
65
|
-
const from = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
|
|
66
|
-
const previousFrom = new Date(now.getFullYear(), now.getMonth() - 1, 1).getTime();
|
|
67
|
-
return {
|
|
68
|
-
from,
|
|
69
|
-
to: nowMs,
|
|
70
|
-
previousFrom,
|
|
71
|
-
previousTo: from
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
const days = period === '7d' ? 7 : period === '30d' ? 30 : 90;
|
|
75
|
-
const from = nowMs - days * DAY_MS;
|
|
76
|
-
return {
|
|
77
|
-
from,
|
|
78
|
-
to: nowMs,
|
|
79
|
-
previousFrom: from - days * DAY_MS,
|
|
80
|
-
previousTo: from
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* The weeks a range divides into: seven-day strides from `from`, the last
|
|
85
|
-
* clipped at `to`.
|
|
86
|
-
*
|
|
87
|
-
* Strides from the START of the period rather than calendar weeks, so that
|
|
88
|
-
* "last 7 days" is one bar and "last 30 days" is four full weeks and two
|
|
89
|
-
* days — the period the reader picked, in the reader's units — instead of
|
|
90
|
-
* whatever fragment of a Monday-to-Sunday week the period happened to start
|
|
91
|
-
* inside. An empty or inverted range has no weeks.
|
|
92
|
-
*/ export function weekBuckets(from, to) {
|
|
93
|
-
const buckets = [];
|
|
94
|
-
if (!(to > from)) return buckets;
|
|
95
|
-
for(let start = from; start < to; start += WEEK_MS){
|
|
96
|
-
buckets.push({
|
|
97
|
-
start,
|
|
98
|
-
end: Math.min(start + WEEK_MS, to)
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
return buckets;
|
|
102
|
-
}
|
|
103
|
-
/**
|
|
104
|
-
* The items of a range, grouped into its weeks.
|
|
105
|
-
*
|
|
106
|
-
* Half-open on every boundary, so an item at exactly midnight between two
|
|
107
|
-
* weeks is in the later one and only the later one, and an item at `to` is
|
|
108
|
-
* outside the period the way an item at `from` is inside it. Items with no
|
|
109
|
-
* usable time are dropped rather than placed anywhere — an undated deal is
|
|
110
|
-
* not a deal closed this week. Every week is returned, empty ones included,
|
|
111
|
-
* because a chart wants a bar per week and a gap where a week had nothing is
|
|
112
|
-
* a chart with the wrong number of bars.
|
|
113
|
-
*/ export function bucketByWeek(items, atMs, from, to) {
|
|
114
|
-
const buckets = weekBuckets(from, to).map((bounds)=>_extends({}, bounds, {
|
|
115
|
-
items: []
|
|
116
|
-
}));
|
|
117
|
-
if (!buckets.length) return buckets;
|
|
118
|
-
for (const item of items){
|
|
119
|
-
const at = atMs(item);
|
|
120
|
-
if (typeof at !== 'number' || !Number.isFinite(at)) continue;
|
|
121
|
-
if (at < from || at >= to) continue;
|
|
122
|
-
const index = Math.min(buckets.length - 1, Math.floor((at - from) / WEEK_MS));
|
|
123
|
-
buckets[index].items.push(item);
|
|
124
|
-
}
|
|
125
|
-
return buckets;
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* The lifecycle funnel a set of per-stage counts describes.
|
|
129
|
-
*
|
|
130
|
-
* A funnel is read CUMULATIVELY: a customer was once a lead, so the "lead"
|
|
131
|
-
* step is everyone who is a lead now or has moved past it, not only the
|
|
132
|
-
* people currently sitting there. That is what makes the steps narrow as
|
|
133
|
-
* they go down and what makes a step-to-step conversion mean something.
|
|
134
|
-
* `other` is a stage a merchant chose for a person the funnel does not
|
|
135
|
-
* describe, so it is reported beside the funnel and never folded into a
|
|
136
|
-
* step. A conversion where the previous step was empty is `null` rather than
|
|
137
|
-
* 0 or 100 — there is nothing to divide by, and either number would be read
|
|
138
|
-
* as a measurement.
|
|
139
|
-
*/ export function funnelFromStages(counts) {
|
|
140
|
-
const countOf = (stage)=>{
|
|
141
|
-
var _counts_stage;
|
|
142
|
-
const value = Number((_counts_stage = counts[stage]) != null ? _counts_stage : 0);
|
|
143
|
-
return Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
|
|
144
|
-
};
|
|
145
|
-
const progression = CONTACT_LIFECYCLE_STAGES.filter((stage)=>stage !== 'other');
|
|
146
|
-
const steps = [];
|
|
147
|
-
let reachedAfter = 0;
|
|
148
|
-
for(let index = progression.length - 1; index >= 0; index -= 1){
|
|
149
|
-
const stage = progression[index];
|
|
150
|
-
const count = countOf(stage);
|
|
151
|
-
const reached = count + reachedAfter;
|
|
152
|
-
steps.unshift({
|
|
153
|
-
stage,
|
|
154
|
-
label: CONTACT_LIFECYCLE_STAGE_LABELS[stage],
|
|
155
|
-
count,
|
|
156
|
-
reached,
|
|
157
|
-
conversion: null
|
|
158
|
-
});
|
|
159
|
-
reachedAfter = reached;
|
|
160
|
-
}
|
|
161
|
-
for(let index = 1; index < steps.length; index += 1){
|
|
162
|
-
const previous = steps[index - 1].reached;
|
|
163
|
-
steps[index].conversion = previous > 0 ? steps[index].reached / previous : null;
|
|
164
|
-
}
|
|
165
|
-
const other = countOf('other');
|
|
166
|
-
return {
|
|
167
|
-
steps,
|
|
168
|
-
other,
|
|
169
|
-
total: steps.reduce((sum, step)=>sum + step.count, 0) + other
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* What a pipeline holds, by stage.
|
|
174
|
-
*
|
|
175
|
-
* Only OPEN deals: a won deal is revenue and a lost one is history, and a
|
|
176
|
-
* pipeline report that counted either would show a stage full of deals that
|
|
177
|
-
* cannot move. Only the pipeline's open-kind stages are rows, for the same
|
|
178
|
-
* reason from the other side — a "Won" column in a pipeline chart is the
|
|
179
|
-
* closed-deals report drawn in the wrong place. The weighting is
|
|
180
|
-
* `weightedDealAmountCents`, applied here rather than restated.
|
|
181
|
-
*/ export function pipelineTotals(deals, pipeline) {
|
|
182
|
-
var _ref;
|
|
183
|
-
const stages = [
|
|
184
|
-
...(_ref = pipeline == null ? void 0 : pipeline.stages) != null ? _ref : []
|
|
185
|
-
].filter((stage)=>stage.kind === 'open').sort((a, b)=>a.order - b.order);
|
|
186
|
-
const rows = new Map(stages.map((stage)=>[
|
|
187
|
-
stage.id,
|
|
188
|
-
{
|
|
189
|
-
stage,
|
|
190
|
-
count: 0,
|
|
191
|
-
amountCents: 0,
|
|
192
|
-
weightedCents: 0
|
|
193
|
-
}
|
|
194
|
-
]));
|
|
195
|
-
const totals = {
|
|
196
|
-
count: 0,
|
|
197
|
-
amountCents: 0,
|
|
198
|
-
weightedCents: 0,
|
|
199
|
-
stages: stages.map((stage)=>rows.get(stage.id)),
|
|
200
|
-
unplaced: {
|
|
201
|
-
count: 0,
|
|
202
|
-
amountCents: 0
|
|
203
|
-
}
|
|
204
|
-
};
|
|
205
|
-
for (const deal of deals){
|
|
206
|
-
var _deal_amountCents;
|
|
207
|
-
if (deal.status !== 'open') continue;
|
|
208
|
-
const amount = Math.max(0, Math.round(Number((_deal_amountCents = deal.amountCents) != null ? _deal_amountCents : 0) || 0));
|
|
209
|
-
totals.count += 1;
|
|
210
|
-
totals.amountCents += amount;
|
|
211
|
-
const row = rows.get(deal.stageId);
|
|
212
|
-
if (!row) {
|
|
213
|
-
totals.unplaced.count += 1;
|
|
214
|
-
totals.unplaced.amountCents += amount;
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
const weighted = weightedDealAmountCents(deal, row.stage);
|
|
218
|
-
row.count += 1;
|
|
219
|
-
row.amountCents += amount;
|
|
220
|
-
row.weightedCents += weighted;
|
|
221
|
-
totals.weightedCents += weighted;
|
|
222
|
-
}
|
|
223
|
-
return totals;
|
|
224
|
-
}
|
|
225
|
-
/** How many months ahead the close-month forecast looks. */ export const FORECAST_MONTHS = 6;
|
|
226
|
-
/**
|
|
227
|
-
* The next `months` calendar months from the one `nowMs` falls in, each
|
|
228
|
-
* `[first of the month, first of the next)` on the reader's calendar.
|
|
229
|
-
*
|
|
230
|
-
* The current month is the first bucket, whole: a deal expected to close
|
|
231
|
-
* on the 3rd when today is the 20th is late, not next month's, and a
|
|
232
|
-
* forecast that started at "now" would drop it between the rows. Local
|
|
233
|
-
* months rather than UTC, because `expectedCloseAtMs` is stored at local
|
|
234
|
-
* noon of the day picked (`dateInputMs`) and a UTC boundary would put a
|
|
235
|
-
* deal picked for the 1st into the month before in half the world.
|
|
236
|
-
*/ export function closeMonthBuckets(nowMs, months = FORECAST_MONTHS) {
|
|
237
|
-
const now = new Date(nowMs);
|
|
238
|
-
const buckets = [];
|
|
239
|
-
for(let offset = 0; offset < months; offset += 1){
|
|
240
|
-
const start = new Date(now.getFullYear(), now.getMonth() + offset, 1);
|
|
241
|
-
const end = new Date(now.getFullYear(), now.getMonth() + offset + 1, 1);
|
|
242
|
-
buckets.push({
|
|
243
|
-
key: `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}`,
|
|
244
|
-
start: start.getTime(),
|
|
245
|
-
end: end.getTime()
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
return buckets;
|
|
249
|
-
}
|
|
250
|
-
const emptyCell = ()=>({
|
|
251
|
-
count: 0,
|
|
252
|
-
amountCents: 0,
|
|
253
|
-
weightedCents: 0
|
|
254
|
-
});
|
|
255
|
-
function addToCell(cell, amountCents, weightedCents) {
|
|
256
|
-
cell.count += 1;
|
|
257
|
-
cell.amountCents += amountCents;
|
|
258
|
-
cell.weightedCents += weightedCents;
|
|
259
|
-
}
|
|
260
|
-
/**
|
|
261
|
-
* The open pipeline laid out by the month each deal is expected to close
|
|
262
|
-
* (AGL-2620): per pipeline, one cell per month for the next
|
|
263
|
-
* {@link FORECAST_MONTHS}, at face value and weighted by the deal's stage.
|
|
264
|
-
*
|
|
265
|
-
* Only open deals, for the reason `pipelineTotals` gives — a closed deal
|
|
266
|
-
* has closed. A deal with no `expectedCloseAtMs` is its own row rather than
|
|
267
|
-
* being dropped or dated "now": a forecast that quietly omitted undated
|
|
268
|
-
* deals would understate the pipeline by exactly the deals nobody has
|
|
269
|
-
* scheduled, which is the number a sales lead most wants to see. A deal
|
|
270
|
-
* dated before the first month is overdue and a deal dated past the last
|
|
271
|
-
* is "later"; both are reported so the rows add up to the pipeline.
|
|
272
|
-
*
|
|
273
|
-
* Every ACTIVE pipeline is a row even with nothing open — an empty
|
|
274
|
-
* pipeline is a fact about the forecast too — while an archived one is a
|
|
275
|
-
* row only if a deal still sits open in it. A deal in a pipeline the list
|
|
276
|
-
* does not carry at all is grouped under its id with no name, so the
|
|
277
|
-
* total still counts it; its stage cannot be resolved, so it weighs
|
|
278
|
-
* nothing, as everywhere else.
|
|
279
|
-
*/ export function forecastByCloseMonth(deals, pipelines, nowMs, months = FORECAST_MONTHS) {
|
|
280
|
-
var _ref, _ref1;
|
|
281
|
-
var _buckets_, _buckets_1;
|
|
282
|
-
const buckets = closeMonthBuckets(nowMs, months);
|
|
283
|
-
const byId = new Map();
|
|
284
|
-
const pipelineRow = (pipelineId)=>{
|
|
285
|
-
let row = byId.get(pipelineId);
|
|
286
|
-
if (!row) {
|
|
287
|
-
var _ref;
|
|
288
|
-
const pipeline = pipelines.find((entry)=>entry.$id === pipelineId);
|
|
289
|
-
row = {
|
|
290
|
-
pipelineId,
|
|
291
|
-
name: (_ref = pipeline == null ? void 0 : pipeline.name) != null ? _ref : '',
|
|
292
|
-
months: buckets.map(emptyCell),
|
|
293
|
-
undated: emptyCell(),
|
|
294
|
-
overdue: emptyCell(),
|
|
295
|
-
later: emptyCell(),
|
|
296
|
-
total: emptyCell()
|
|
297
|
-
};
|
|
298
|
-
byId.set(pipelineId, row);
|
|
299
|
-
}
|
|
300
|
-
return row;
|
|
301
|
-
};
|
|
302
|
-
for (const pipeline of pipelines){
|
|
303
|
-
if (!isPipelineArchived(pipeline)) pipelineRow(pipeline.$id);
|
|
304
|
-
}
|
|
305
|
-
const totals = {
|
|
306
|
-
buckets,
|
|
307
|
-
pipelines: [],
|
|
308
|
-
months: buckets.map(emptyCell),
|
|
309
|
-
undated: emptyCell(),
|
|
310
|
-
overdue: emptyCell(),
|
|
311
|
-
later: emptyCell(),
|
|
312
|
-
total: emptyCell()
|
|
313
|
-
};
|
|
314
|
-
const first = (_ref = (_buckets_ = buckets[0]) == null ? void 0 : _buckets_.start) != null ? _ref : nowMs;
|
|
315
|
-
const last = (_ref1 = (_buckets_1 = buckets[buckets.length - 1]) == null ? void 0 : _buckets_1.end) != null ? _ref1 : nowMs;
|
|
316
|
-
for (const deal of deals){
|
|
317
|
-
var _deal_pipelineId, _deal_amountCents;
|
|
318
|
-
if (deal.status !== 'open') continue;
|
|
319
|
-
const pipelineId = String((_deal_pipelineId = deal.pipelineId) != null ? _deal_pipelineId : '');
|
|
320
|
-
const row = pipelineRow(pipelineId);
|
|
321
|
-
const pipeline = pipelines.find((entry)=>entry.$id === pipelineId);
|
|
322
|
-
const amount = Math.max(0, Math.round(Number((_deal_amountCents = deal.amountCents) != null ? _deal_amountCents : 0) || 0));
|
|
323
|
-
const weighted = weightedDealAmountCents(deal, dealStageById(pipeline, deal.stageId));
|
|
324
|
-
const at = deal.expectedCloseAtMs;
|
|
325
|
-
const cells = [
|
|
326
|
-
[
|
|
327
|
-
row.total,
|
|
328
|
-
totals.total
|
|
329
|
-
]
|
|
330
|
-
];
|
|
331
|
-
if (typeof at !== 'number' || !Number.isFinite(at)) {
|
|
332
|
-
cells.push([
|
|
333
|
-
row.undated,
|
|
334
|
-
totals.undated
|
|
335
|
-
]);
|
|
336
|
-
} else if (at < first) {
|
|
337
|
-
cells.push([
|
|
338
|
-
row.overdue,
|
|
339
|
-
totals.overdue
|
|
340
|
-
]);
|
|
341
|
-
} else if (at >= last) {
|
|
342
|
-
cells.push([
|
|
343
|
-
row.later,
|
|
344
|
-
totals.later
|
|
345
|
-
]);
|
|
346
|
-
} else {
|
|
347
|
-
const index = buckets.findIndex((bucket)=>at >= bucket.start && at < bucket.end);
|
|
348
|
-
cells.push([
|
|
349
|
-
row.months[index],
|
|
350
|
-
totals.months[index]
|
|
351
|
-
]);
|
|
352
|
-
}
|
|
353
|
-
for (const [own, all] of cells){
|
|
354
|
-
addToCell(own, amount, weighted);
|
|
355
|
-
addToCell(all, amount, weighted);
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
totals.pipelines = [
|
|
359
|
-
...byId.values()
|
|
360
|
-
];
|
|
361
|
-
return totals;
|
|
362
|
-
}
|
|
363
|
-
/**
|
|
364
|
-
* How many of a list share each key, most common first.
|
|
365
|
-
*
|
|
366
|
-
* Ties break on the key so two renders of the same data draw the same order.
|
|
367
|
-
* An item with no key is not "a key called empty": a contact whose facet
|
|
368
|
-
* names no source is left out of a by-source chart rather than drawn as a
|
|
369
|
-
* bar labeled with nothing.
|
|
370
|
-
*/ export function tally(items, keyOf) {
|
|
371
|
-
const counts = new Map();
|
|
372
|
-
for (const item of items){
|
|
373
|
-
var _counts_get;
|
|
374
|
-
const key = keyOf(item);
|
|
375
|
-
if (!key) continue;
|
|
376
|
-
counts.set(key, ((_counts_get = counts.get(key)) != null ? _counts_get : 0) + 1);
|
|
377
|
-
}
|
|
378
|
-
return [
|
|
379
|
-
...counts.entries()
|
|
380
|
-
].map(([key, count])=>({
|
|
381
|
-
key,
|
|
382
|
-
count
|
|
383
|
-
})).sort((a, b)=>b.count - a.count || a.key.localeCompare(b.key));
|
|
384
|
-
}
|
|
385
|
-
/**
|
|
386
|
-
* Percentage change against the previous period, to one decimal, or `null`
|
|
387
|
-
* when the previous period had nothing.
|
|
388
|
-
*
|
|
389
|
-
* `null` rather than a number because every number is wrong: "+100%" reads
|
|
390
|
-
* as growth, "+0%" reads as flat, and a first contact has no growth rate.
|
|
391
|
-
* The tile that renders this draws nothing for `null`, which is the honest
|
|
392
|
-
* rendering of "there is no baseline".
|
|
393
|
-
*/ export function deltaPercent(current, previous) {
|
|
394
|
-
if (!(previous > 0)) return null;
|
|
395
|
-
return Math.round((current - previous) / previous * 1000) / 10;
|
|
396
|
-
}
|
|
397
|
-
/**
|
|
398
|
-
* The currency a set of deals is in, and whether they are actually in more
|
|
399
|
-
* than one.
|
|
400
|
-
*
|
|
401
|
-
* A pipeline total is a sum, and a sum of dollars and euros is a number
|
|
402
|
-
* with no unit. The reports still show that number — a merchant with one
|
|
403
|
-
* stray euro deal wants the pipeline figure, not a refusal — but they say
|
|
404
|
-
* it is mixed. The most common currency names the total; `usd` is what a
|
|
405
|
-
* deal with no currency means, per the model.
|
|
406
|
-
*/ export function currencyOfDeals(deals) {
|
|
407
|
-
var _ref;
|
|
408
|
-
var _ranked_;
|
|
409
|
-
const ranked = tally(deals, (deal)=>String(deal.currency || 'usd').toLowerCase());
|
|
410
|
-
return {
|
|
411
|
-
currency: (_ref = (_ranked_ = ranked[0]) == null ? void 0 : _ranked_.key) != null ? _ref : 'usd',
|
|
412
|
-
mixed: ranked.length > 1
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
/**
|
|
416
|
-
* The local calendar day a moment falls in, `[start, end)`.
|
|
417
|
-
*
|
|
418
|
-
* The same day `taskDueState` decides "today" on, so a count of tasks due
|
|
419
|
-
* today and the list's "today" chip agree about the same task at 11 p.m.
|
|
420
|
-
*/ export function localDayBounds(nowMs) {
|
|
421
|
-
const now = new Date(nowMs);
|
|
422
|
-
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
423
|
-
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
|
424
|
-
return {
|
|
425
|
-
start: start.getTime(),
|
|
426
|
-
end: end.getTime()
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
/**
|
|
430
|
-
* Who did what: the activities and the completed tasks of a period,
|
|
431
|
-
* grouped by the person who did them, busiest first.
|
|
432
|
-
*
|
|
433
|
-
* An activity is credited to whoever logged it. A task is credited to
|
|
434
|
-
* whoever COMPLETED it when the route stamped that, and to its assignee
|
|
435
|
-
* otherwise: the leaderboard measures work done, and a manager closing out
|
|
436
|
-
* a departed teammate's list did that work. A kind the model does not
|
|
437
|
-
* name counts as `other` rather than vanishing — the activity happened.
|
|
438
|
-
*
|
|
439
|
-
* Busiest means activities plus tasks; ties break on activities, then on
|
|
440
|
-
* the uid, so two renders of one window draw one order.
|
|
441
|
-
*/ export function activityLeaderboard(activities, tasksDone) {
|
|
442
|
-
const rows = new Map();
|
|
443
|
-
/** When each row's name was signed, so a newer signature replaces an older one. */ const namedAtMs = new Map();
|
|
444
|
-
const rowFor = (uid)=>{
|
|
445
|
-
let row = rows.get(uid);
|
|
446
|
-
if (!row) {
|
|
447
|
-
row = {
|
|
448
|
-
uid,
|
|
449
|
-
name: null,
|
|
450
|
-
kinds: Object.fromEntries(CRM_ACTIVITY_KINDS.map((kind)=>[
|
|
451
|
-
kind,
|
|
452
|
-
0
|
|
453
|
-
])),
|
|
454
|
-
activities: 0,
|
|
455
|
-
tasksDone: 0
|
|
456
|
-
};
|
|
457
|
-
rows.set(uid, row);
|
|
458
|
-
}
|
|
459
|
-
return row;
|
|
460
|
-
};
|
|
461
|
-
for (const activity of activities){
|
|
462
|
-
var _activity_byUid, _activity_byName, _namedAtMs_get;
|
|
463
|
-
const row = rowFor(String((_activity_byUid = activity.byUid) != null ? _activity_byUid : ''));
|
|
464
|
-
const kind = isCrmActivityKind(activity.kind) ? activity.kind : 'other';
|
|
465
|
-
row.kinds[kind] += 1;
|
|
466
|
-
row.activities += 1;
|
|
467
|
-
const name = String((_activity_byName = activity.byName) != null ? _activity_byName : '').trim();
|
|
468
|
-
const atMs = Number(activity.atMs);
|
|
469
|
-
if (name && Number.isFinite(atMs) && atMs > ((_namedAtMs_get = namedAtMs.get(row.uid)) != null ? _namedAtMs_get : Number.NEGATIVE_INFINITY)) {
|
|
470
|
-
row.name = name;
|
|
471
|
-
namedAtMs.set(row.uid, atMs);
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
for (const task of tasksDone){
|
|
475
|
-
rowFor(String(task.completedByUid || task.assigneeUid || '')).tasksDone += 1;
|
|
476
|
-
}
|
|
477
|
-
return [
|
|
478
|
-
...rows.values()
|
|
479
|
-
].sort((a, b)=>b.activities + b.tasksDone - (a.activities + a.tasksDone) || b.activities - a.activities || a.uid.localeCompare(b.uid));
|
|
480
|
-
}
|
|
481
|
-
/*==========================================
|
|
482
|
-
* LEAD FUNNEL (AGL-2624)
|
|
483
|
-
*=========================================*/ /** The key a lead unqualified with no reason is tallied under. */ export const LEAD_NO_REASON_KEY = '$none';
|
|
484
|
-
export const LEAD_NO_REASON_LABEL = 'No reason given';
|
|
485
|
-
/**
|
|
486
|
-
* Where a set of leads stands, and why the ones that were closed without
|
|
487
|
-
* converting were closed.
|
|
488
|
-
*
|
|
489
|
-
* A status breakdown rather than a cumulative funnel, because a lead's
|
|
490
|
-
* history is not on the document — only where it is now — and a lead can
|
|
491
|
-
* be qualified straight from `new` without ever having been `working`. A
|
|
492
|
-
* step that claimed "reached working" would be inventing a past. The two
|
|
493
|
-
* rates are over EVERY lead handed in, so a period in which half the leads
|
|
494
|
-
* are still untouched reads as half unconverted, which is what it is.
|
|
495
|
-
*
|
|
496
|
-
* Reasons are free text — the unqualify dialog requires one but does not
|
|
497
|
-
* offer a list — so `Too small` and `too small` are one reason and the
|
|
498
|
-
* label is whichever spelling came first. An unqualified lead written past
|
|
499
|
-
* the dialog with no reason is counted under its own key rather than
|
|
500
|
-
* dropped: the lead was closed, and "no reason given" is the count a
|
|
501
|
-
* manager wants to see.
|
|
502
|
-
*/ export function leadFunnel(leads) {
|
|
503
|
-
const byStatus = Object.fromEntries(CRM_LEAD_STATUSES.map((status)=>[
|
|
504
|
-
status,
|
|
505
|
-
0
|
|
506
|
-
]));
|
|
507
|
-
const reasons = new Map();
|
|
508
|
-
for (const lead of leads){
|
|
509
|
-
var _lead_unqualifiedReason, _reasons_get;
|
|
510
|
-
const status = crmLeadStatus(lead);
|
|
511
|
-
byStatus[status] += 1;
|
|
512
|
-
if (status !== 'unqualified') continue;
|
|
513
|
-
const label = String((_lead_unqualifiedReason = lead.unqualifiedReason) != null ? _lead_unqualifiedReason : '').replace(/\s+/g, ' ').trim();
|
|
514
|
-
const key = label ? label.toLowerCase() : LEAD_NO_REASON_KEY;
|
|
515
|
-
const row = (_reasons_get = reasons.get(key)) != null ? _reasons_get : {
|
|
516
|
-
key,
|
|
517
|
-
label: label || LEAD_NO_REASON_LABEL,
|
|
518
|
-
count: 0
|
|
519
|
-
};
|
|
520
|
-
row.count += 1;
|
|
521
|
-
reasons.set(key, row);
|
|
522
|
-
}
|
|
523
|
-
const total = leads.length;
|
|
524
|
-
return {
|
|
525
|
-
total,
|
|
526
|
-
byStatus,
|
|
527
|
-
open: byStatus.new + byStatus.working,
|
|
528
|
-
qualifiedRate: total > 0 ? byStatus.qualified / total : null,
|
|
529
|
-
unqualifiedRate: total > 0 ? byStatus.unqualified / total : null,
|
|
530
|
-
reasons: [
|
|
531
|
-
...reasons.values()
|
|
532
|
-
].sort((a, b)=>b.count - a.count || a.label.localeCompare(b.label))
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
/**
|
|
536
|
-
* The leads still needing somebody, from two server counts: every lead,
|
|
537
|
-
* less the ones closed one way or the other.
|
|
538
|
-
*
|
|
539
|
-
* Subtraction rather than a count of the open statuses because a lead
|
|
540
|
-
* nobody has touched carries NO status field — see `crmLeadStatus` — and
|
|
541
|
-
* Firestore cannot select a document by a field's absence. The closed
|
|
542
|
-
* statuses are always written, so they can be counted; what remains is
|
|
543
|
-
* open. Clamped at zero for the moment between the two counts in which a
|
|
544
|
-
* lead was closed.
|
|
545
|
-
*/ export function openLeadsFromCounts(total, closed) {
|
|
546
|
-
return Math.max(0, Math.round(Number(total) || 0) - Math.round(Number(closed) || 0));
|
|
547
|
-
}
|
|
548
|
-
/*==========================================
|
|
549
|
-
* CONVERSION BY SOURCE (AGL-2624)
|
|
550
|
-
*=========================================*/ /**
|
|
551
|
-
* The stages that mean a person has bought, or better.
|
|
552
|
-
*
|
|
553
|
-
* `customer` and `evangelist` only. `other` sits after `customer` in the
|
|
554
|
-
* stage list so that no capture can overwrite it, but it names a step the
|
|
555
|
-
* business chose and the report cannot know whether that step is past a
|
|
556
|
-
* purchase; counting it would credit a source with a sale nobody recorded.
|
|
557
|
-
*/ const CUSTOMER_STAGES = new Set([
|
|
558
|
-
'customer',
|
|
559
|
-
'evangelist'
|
|
560
|
-
]);
|
|
561
|
-
/**
|
|
562
|
-
* Whether this holder's view of a person says they have bought: a stage
|
|
563
|
-
* at `customer` or past it, or an order on the books whatever the stage
|
|
564
|
-
* says — an order path that never advanced the stage still sold something.
|
|
565
|
-
*/ export function contactIsCustomer(facet) {
|
|
566
|
-
var _facet_ordersCount;
|
|
567
|
-
if (facet.lifecycleStage && CUSTOMER_STAGES.has(facet.lifecycleStage)) return true;
|
|
568
|
-
const orders = Number((_facet_ordersCount = facet.ordersCount) != null ? _facet_ordersCount : 0);
|
|
569
|
-
return Number.isFinite(orders) && orders > 0;
|
|
570
|
-
}
|
|
571
|
-
/**
|
|
572
|
-
* Which capture surfaces turn into customers.
|
|
573
|
-
*
|
|
574
|
-
* Read through one holder's facet, like every by-source figure: a source
|
|
575
|
-
* is which of THIS business's doors the person came through. A person
|
|
576
|
-
* captured two ways counts under both — the question is "how well does
|
|
577
|
-
* each door convert", and a person who came through two is evidence for
|
|
578
|
-
* both — so the rows can add to more than `total`, and `customers` counts
|
|
579
|
-
* each person once for the tile above the table. Rows rank by captured,
|
|
580
|
-
* not by rate, because a door with one visitor who bought is not the best
|
|
581
|
-
* door.
|
|
582
|
-
*/ export function conversionBySource(facets) {
|
|
583
|
-
const rows = new Map();
|
|
584
|
-
let unsourced = 0;
|
|
585
|
-
let customers = 0;
|
|
586
|
-
for (const facet of facets){
|
|
587
|
-
var _facet_sources;
|
|
588
|
-
const isCustomer = contactIsCustomer(facet);
|
|
589
|
-
if (isCustomer) customers += 1;
|
|
590
|
-
const sources = Object.keys((_facet_sources = facet.sources) != null ? _facet_sources : {}).filter((source)=>{
|
|
591
|
-
var _facet_sources;
|
|
592
|
-
return (_facet_sources = facet.sources) == null ? void 0 : _facet_sources[source];
|
|
593
|
-
});
|
|
594
|
-
if (!sources.length) {
|
|
595
|
-
unsourced += 1;
|
|
596
|
-
continue;
|
|
597
|
-
}
|
|
598
|
-
for (const source of sources){
|
|
599
|
-
var _rows_get;
|
|
600
|
-
const row = (_rows_get = rows.get(source)) != null ? _rows_get : {
|
|
601
|
-
source,
|
|
602
|
-
captured: 0,
|
|
603
|
-
customers: 0,
|
|
604
|
-
rate: 0
|
|
605
|
-
};
|
|
606
|
-
row.captured += 1;
|
|
607
|
-
if (isCustomer) row.customers += 1;
|
|
608
|
-
rows.set(source, row);
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
return {
|
|
612
|
-
rows: [
|
|
613
|
-
...rows.values()
|
|
614
|
-
].map((row)=>_extends({}, row, {
|
|
615
|
-
rate: row.customers / row.captured
|
|
616
|
-
})).sort((a, b)=>b.captured - a.captured || b.customers - a.customers || a.source.localeCompare(b.source)),
|
|
617
|
-
unsourced,
|
|
618
|
-
customers,
|
|
619
|
-
total: facets.length
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
|
-
/** The key an unowned deal is tallied under — see {@link OwnerOutcomeRow.uid}. */ export const OWNER_UNASSIGNED_KEY = '';
|
|
623
|
-
/**
|
|
624
|
-
* Who closed what, in the period's window.
|
|
625
|
-
*
|
|
626
|
-
* The by-owner reading of the same two sets the Won and lost card already
|
|
627
|
-
* holds — everything that closed won, and everything that closed lost — so
|
|
628
|
-
* this adds no read. Credited to `ownerUid`, the field the board and the
|
|
629
|
-
* bulk bar both write, and never to the creator: a deal reassigned mid-cycle
|
|
630
|
-
* belongs to whoever carried it at the close, which is what "by owner"
|
|
631
|
-
* is asked to mean when a manager reads it.
|
|
632
|
-
*
|
|
633
|
-
* Ranked by how much CLOSED rather than by win rate, for the reason
|
|
634
|
-
* `conversionBySource` ranks by captured: an owner who closed one deal and
|
|
635
|
-
* won it is not the best closer on the team. Ties break on wins, then on
|
|
636
|
-
* the uid, so two renders of one window draw one order.
|
|
637
|
-
*/ export function wonLostByOwner(won, lost) {
|
|
638
|
-
const rows = new Map();
|
|
639
|
-
const rowFor = (uid)=>{
|
|
640
|
-
let row = rows.get(uid);
|
|
641
|
-
if (!row) {
|
|
642
|
-
row = {
|
|
643
|
-
uid,
|
|
644
|
-
won: 0,
|
|
645
|
-
lost: 0,
|
|
646
|
-
wonAmountCents: 0,
|
|
647
|
-
lostAmountCents: 0,
|
|
648
|
-
closed: 0,
|
|
649
|
-
winRate: null
|
|
650
|
-
};
|
|
651
|
-
rows.set(uid, row);
|
|
652
|
-
}
|
|
653
|
-
return row;
|
|
654
|
-
};
|
|
655
|
-
/** A missing or negative amount counts as nothing, never as a debit. */ const cents = (deal)=>{
|
|
656
|
-
var _deal_amountCents;
|
|
657
|
-
return Math.max(0, Number((_deal_amountCents = deal.amountCents) != null ? _deal_amountCents : 0) || 0);
|
|
658
|
-
};
|
|
659
|
-
for (const deal of won){
|
|
660
|
-
var _deal_ownerUid;
|
|
661
|
-
const row = rowFor(String((_deal_ownerUid = deal.ownerUid) != null ? _deal_ownerUid : OWNER_UNASSIGNED_KEY));
|
|
662
|
-
row.won += 1;
|
|
663
|
-
row.closed += 1;
|
|
664
|
-
row.wonAmountCents += cents(deal);
|
|
665
|
-
}
|
|
666
|
-
for (const deal of lost){
|
|
667
|
-
var _deal_ownerUid1;
|
|
668
|
-
const row = rowFor(String((_deal_ownerUid1 = deal.ownerUid) != null ? _deal_ownerUid1 : OWNER_UNASSIGNED_KEY));
|
|
669
|
-
row.lost += 1;
|
|
670
|
-
row.closed += 1;
|
|
671
|
-
row.lostAmountCents += cents(deal);
|
|
672
|
-
}
|
|
673
|
-
return [
|
|
674
|
-
...rows.values()
|
|
675
|
-
].map((row)=>_extends({}, row, {
|
|
676
|
-
winRate: row.closed ? row.won / row.closed : null
|
|
677
|
-
})).sort((a, b)=>b.closed - a.closed || b.won - a.won || a.uid.localeCompare(b.uid));
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
//# sourceMappingURL=crm-reports.js.map
|