@aglyn/plugins-crm 1.0.0-beta.164 → 1.0.0-beta.166

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/contact-associations-card.js +21 -5
  3. package/src/lib/components/contact-associations-card.js.map +1 -1
  4. package/src/lib/components/contact-field-drawer.js +19 -2
  5. package/src/lib/components/contact-field-drawer.js.map +1 -1
  6. package/src/lib/components/crm-console-page.js +12 -7
  7. package/src/lib/components/crm-console-page.js.map +1 -1
  8. package/src/lib/components/crm-glance-card.js +4 -2
  9. package/src/lib/components/crm-glance-card.js.map +1 -1
  10. package/src/lib/components/fields-section.d.ts +2 -1
  11. package/src/lib/components/fields-section.js +6 -3
  12. package/src/lib/components/fields-section.js.map +1 -1
  13. package/src/lib/components/lead-campaigns-card.d.ts +59 -0
  14. package/src/lib/components/lead-campaigns-card.js +166 -0
  15. package/src/lib/components/lead-campaigns-card.js.map +1 -0
  16. package/src/lib/components/lead-detail-page.js +54 -4
  17. package/src/lib/components/lead-detail-page.js.map +1 -1
  18. package/src/lib/components/lead-history-card.js +5 -0
  19. package/src/lib/components/lead-history-card.js.map +1 -1
  20. package/src/lib/components/lead-properties-card.d.ts +12 -0
  21. package/src/lib/components/lead-properties-card.js +64 -6
  22. package/src/lib/components/lead-properties-card.js.map +1 -1
  23. package/src/lib/components/lead-unqualify-dialog.js +7 -1
  24. package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
  25. package/src/lib/components/leads-bulk-bar.d.ts +7 -2
  26. package/src/lib/components/leads-bulk-bar.js +42 -6
  27. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  28. package/src/lib/components/leads-section.js +43 -17
  29. package/src/lib/components/leads-section.js.map +1 -1
  30. package/src/lib/components/new-lead-drawer.d.ts +14 -1
  31. package/src/lib/components/new-lead-drawer.js +41 -3
  32. package/src/lib/components/new-lead-drawer.js.map +1 -1
  33. package/src/lib/components/reports/lead-counts.d.ts +16 -9
  34. package/src/lib/components/reports/lead-counts.js +29 -18
  35. package/src/lib/components/reports/lead-counts.js.map +1 -1
  36. package/src/lib/components/reports/lead-funnel-card.js +44 -40
  37. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  38. package/src/lib/hooks/use-campaign-filing-log.d.ts +29 -0
  39. package/src/lib/hooks/use-campaign-filing-log.js +91 -0
  40. package/src/lib/hooks/use-campaign-filing-log.js.map +1 -0
  41. package/src/lib/hooks/use-org-leads.d.ts +29 -32
  42. package/src/lib/hooks/use-org-leads.js +58 -68
  43. package/src/lib/hooks/use-org-leads.js.map +1 -1
  44. package/src/lib/model/campaign-filing-activity.d.ts +82 -0
  45. package/src/lib/model/campaign-filing-activity.js +88 -0
  46. package/src/lib/model/campaign-filing-activity.js.map +1 -0
  47. package/src/lib/model/crm-routes.d.ts +9 -6
  48. package/src/lib/model/crm-routes.js +9 -6
  49. package/src/lib/model/crm-routes.js.map +1 -1
  50. package/src/lib/server/campaign-filing-activity.d.ts +79 -0
  51. package/src/lib/server/campaign-filing-activity.js +93 -0
  52. package/src/lib/server/campaign-filing-activity.js.map +1 -0
  53. package/src/lib/server/capture-contact.js +11 -5
  54. package/src/lib/server/capture-contact.js.map +1 -1
  55. package/src/lib/server/convert-open-lead.js +6 -2
  56. package/src/lib/server/convert-open-lead.js.map +1 -1
  57. package/src/lib/server/email-send.js +3 -3
  58. package/src/lib/server/email-send.js.map +1 -1
  59. package/src/lib/server/erase-person.js +25 -7
  60. package/src/lib/server/erase-person.js.map +1 -1
  61. package/src/lib/server/lead-campaign-carry.js +28 -2
  62. package/src/lib/server/lead-campaign-carry.js.map +1 -1
  63. package/src/lib/server/lead-create.d.ts +15 -4
  64. package/src/lib/server/lead-create.js +75 -15
  65. package/src/lib/server/lead-create.js.map +1 -1
  66. package/src/lib/server/leads-import.js +2 -2
  67. package/src/lib/server/leads-import.js.map +1 -1
  68. package/src/lib/server/record-email-state.js +10 -0
  69. package/src/lib/server/record-email-state.js.map +1 -1
  70. package/src/lib/server/record-facts.js +3 -3
  71. package/src/lib/server/record-facts.js.map +1 -1
@@ -19,14 +19,21 @@ import { type Firestore } from 'firebase/firestore';
19
19
  * THE LEADS STILL TO WORK, a site at a time (AGL-2624, across sites since
20
20
  * AGL-2634).
21
21
  *
22
- * A lead lives under its site `hosts/{hostId}/leads`, private by path —
23
- * so there is no org-level collection to count and no `orgId` on the
24
- * document to group by. The one figure is two server counts per site and
25
- * a subtraction: every lead, less the closed ones, because a lead nobody
22
+ * ONE PAIR OF COUNTS, not one pair per site (AGL-3275).
23
+ *
24
+ * This fanned out across the org's sites and summed, because a lead lived at
25
+ * `hosts/{hostId}/leads` and each site's collection was disjoint from every
26
+ * other's. On the org they share one collection, and a lead two brands both
27
+ * hold would be counted once for each of them — so the sum would report a
28
+ * multi-brand org more open leads than it has. A single scoped count cannot
29
+ * double-count, and costs two reads instead of two per site.
30
+ *
31
+ * The figure is still every lead less the closed ones, because a lead nobody
26
32
  * has touched carries no status and Firestore cannot select on a field's
27
- * absence (`openLeadsFromCounts`). Handed one site this is the glance
28
- * card's figure as it always was; handed the org's sites it is the org
29
- * level's total, and the fan-out is bounded by the org's site list rather
30
- * than by the data.
33
+ * absence (`openLeadsFromCounts`).
34
+ *
35
+ * `visibleTo` is the scope clause: a site's tokens for the glance card, and
36
+ * `null` at the organization level, where an org-wide member's reads carry no
37
+ * clause at all.
31
38
  */
32
- export declare function openLeadsAcrossSites(firestore: Firestore, hostIds: readonly string[]): Promise<number>;
39
+ export declare function openLeadsForScope(firestore: Firestore, orgId: string, visibleTo: readonly string[] | null): Promise<number>;
@@ -19,26 +19,37 @@ import { collection, getCountFromServer, query, where } from "firebase/firestore
19
19
  * THE LEADS STILL TO WORK, a site at a time (AGL-2624, across sites since
20
20
  * AGL-2634).
21
21
  *
22
- * A lead lives under its site `hosts/{hostId}/leads`, private by path —
23
- * so there is no org-level collection to count and no `orgId` on the
24
- * document to group by. The one figure is two server counts per site and
25
- * a subtraction: every lead, less the closed ones, because a lead nobody
22
+ * ONE PAIR OF COUNTS, not one pair per site (AGL-3275).
23
+ *
24
+ * This fanned out across the org's sites and summed, because a lead lived at
25
+ * `hosts/{hostId}/leads` and each site's collection was disjoint from every
26
+ * other's. On the org they share one collection, and a lead two brands both
27
+ * hold would be counted once for each of them — so the sum would report a
28
+ * multi-brand org more open leads than it has. A single scoped count cannot
29
+ * double-count, and costs two reads instead of two per site.
30
+ *
31
+ * The figure is still every lead less the closed ones, because a lead nobody
26
32
  * has touched carries no status and Firestore cannot select on a field's
27
- * absence (`openLeadsFromCounts`). Handed one site this is the glance
28
- * card's figure as it always was; handed the org's sites it is the org
29
- * level's total, and the fan-out is bounded by the org's site list rather
30
- * than by the data.
31
- */ export async function openLeadsAcrossSites(firestore, hostIds) {
33
+ * absence (`openLeadsFromCounts`).
34
+ *
35
+ * `visibleTo` is the scope clause: a site's tokens for the glance card, and
36
+ * `null` at the organization level, where an org-wide member's reads carry no
37
+ * clause at all.
38
+ */ export async function openLeadsForScope(firestore, orgId, visibleTo) {
32
39
  const countOf = (target)=>getCountFromServer(target).then((snapshot)=>snapshot.data().count);
33
- const perSite = await Promise.all(hostIds.map(async (hostId)=>{
34
- const leads = collection(firestore, 'hosts', hostId, 'leads');
35
- const [total, closed] = await Promise.all([
36
- countOf(query(leads)),
37
- countOf(query(leads, where('status', 'in', CRM_LEAD_CLOSED_STATUSES)))
38
- ]);
39
- return openLeadsFromCounts(total, closed);
40
- }));
41
- return perSite.reduce((sum, count)=>sum + count, 0);
40
+ const leads = collection(firestore, 'orgs', orgId, 'leads');
41
+ // An `array-contains-any` over nothing is a query Firestore refuses.
42
+ if (visibleTo && !visibleTo.length) return 0;
43
+ const scope = visibleTo ? [
44
+ where('visibleTo', 'array-contains-any', [
45
+ ...visibleTo
46
+ ])
47
+ ] : [];
48
+ const [total, closed] = await Promise.all([
49
+ countOf(query(leads, ...scope)),
50
+ countOf(query(leads, ...scope, where('status', 'in', CRM_LEAD_CLOSED_STATUSES)))
51
+ ]);
52
+ return openLeadsFromCounts(total, closed);
42
53
  }
43
54
 
44
55
  //# sourceMappingURL=lead-counts.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/lead-counts.ts"],"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\nimport { CRM_LEAD_CLOSED_STATUSES, openLeadsFromCounts } from '@aglyn/aglyn'\nimport {\n collection,\n type Firestore,\n getCountFromServer,\n query,\n where,\n} from 'firebase/firestore'\n\n/**\n * THE LEADS STILL TO WORK, a site at a time (AGL-2624, across sites since\n * AGL-2634).\n *\n * A lead lives under its site `hosts/{hostId}/leads`, private by path —\n * so there is no org-level collection to count and no `orgId` on the\n * document to group by. The one figure is two server counts per site and\n * a subtraction: every lead, less the closed ones, because a lead nobody\n * has touched carries no status and Firestore cannot select on a field's\n * absence (`openLeadsFromCounts`). Handed one site this is the glance\n * card's figure as it always was; handed the org's sites it is the org\n * level's total, and the fan-out is bounded by the org's site list rather\n * than by the data.\n */\nexport async function openLeadsAcrossSites(\n firestore: Firestore,\n hostIds: readonly string[],\n): Promise<number> {\n const countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n const perSite = await Promise.all(\n hostIds.map(async (hostId) => {\n const leads = collection(firestore, 'hosts', hostId, 'leads')\n const [total, closed] = await Promise.all([\n countOf(query(leads)),\n countOf(query(leads, where('status', 'in', CRM_LEAD_CLOSED_STATUSES))),\n ])\n return openLeadsFromCounts(total, closed)\n }),\n )\n return perSite.reduce((sum, count) => sum + count, 0)\n}\n"],"names":["CRM_LEAD_CLOSED_STATUSES","openLeadsFromCounts","collection","getCountFromServer","query","where","openLeadsAcrossSites","firestore","hostIds","countOf","target","then","snapshot","data","count","perSite","Promise","all","map","hostId","leads","total","closed","reduce","sum"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,wBAAwB,EAAEC,mBAAmB,QAAQ,eAAc;AAC5E,SACEC,UAAU,EAEVC,kBAAkB,EAClBC,KAAK,EACLC,KAAK,QACA,qBAAoB;AAE3B;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAeC,qBACpBC,SAAoB,EACpBC,OAA0B;IAE1B,MAAMC,UAAU,CAACC,SACfP,mBAAmBO,QAAQC,IAAI,CAAC,CAACC,WAAaA,SAASC,IAAI,GAAGC,KAAK;IACrE,MAAMC,UAAU,MAAMC,QAAQC,GAAG,CAC/BT,QAAQU,GAAG,CAAC,OAAOC;QACjB,MAAMC,QAAQlB,WAAWK,WAAW,SAASY,QAAQ;QACrD,MAAM,CAACE,OAAOC,OAAO,GAAG,MAAMN,QAAQC,GAAG,CAAC;YACxCR,QAAQL,MAAMgB;YACdX,QAAQL,MAAMgB,OAAOf,MAAM,UAAU,MAAML;SAC5C;QACD,OAAOC,oBAAoBoB,OAAOC;IACpC;IAEF,OAAOP,QAAQQ,MAAM,CAAC,CAACC,KAAKV,QAAUU,MAAMV,OAAO;AACrD"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/lead-counts.ts"],"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\nimport { CRM_LEAD_CLOSED_STATUSES, openLeadsFromCounts } from '@aglyn/aglyn'\nimport {\n collection,\n type Firestore,\n getCountFromServer,\n query,\n where,\n} from 'firebase/firestore'\n\n/**\n * THE LEADS STILL TO WORK, a site at a time (AGL-2624, across sites since\n * AGL-2634).\n *\n * ONE PAIR OF COUNTS, not one pair per site (AGL-3275).\n *\n * This fanned out across the org's sites and summed, because a lead lived at\n * `hosts/{hostId}/leads` and each site's collection was disjoint from every\n * other's. On the org they share one collection, and a lead two brands both\n * hold would be counted once for each of them — so the sum would report a\n * multi-brand org more open leads than it has. A single scoped count cannot\n * double-count, and costs two reads instead of two per site.\n *\n * The figure is still every lead less the closed ones, because a lead nobody\n * has touched carries no status and Firestore cannot select on a field's\n * absence (`openLeadsFromCounts`).\n *\n * `visibleTo` is the scope clause: a site's tokens for the glance card, and\n * `null` at the organization level, where an org-wide member's reads carry no\n * clause at all.\n */\nexport async function openLeadsForScope(\n firestore: Firestore,\n orgId: string,\n visibleTo: readonly string[] | null,\n): Promise<number> {\n const countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n const leads = collection(firestore, 'orgs', orgId, 'leads')\n // An `array-contains-any` over nothing is a query Firestore refuses.\n if (visibleTo && !visibleTo.length) return 0\n const scope = visibleTo\n ? [where('visibleTo', 'array-contains-any', [...visibleTo])]\n : []\n const [total, closed] = await Promise.all([\n countOf(query(leads, ...scope)),\n countOf(\n query(leads, ...scope, where('status', 'in', CRM_LEAD_CLOSED_STATUSES)),\n ),\n ])\n return openLeadsFromCounts(total, closed)\n}\n"],"names":["CRM_LEAD_CLOSED_STATUSES","openLeadsFromCounts","collection","getCountFromServer","query","where","openLeadsForScope","firestore","orgId","visibleTo","countOf","target","then","snapshot","data","count","leads","length","scope","total","closed","Promise","all"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,wBAAwB,EAAEC,mBAAmB,QAAQ,eAAc;AAC5E,SACEC,UAAU,EAEVC,kBAAkB,EAClBC,KAAK,EACLC,KAAK,QACA,qBAAoB;AAE3B;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,eAAeC,kBACpBC,SAAoB,EACpBC,KAAa,EACbC,SAAmC;IAEnC,MAAMC,UAAU,CAACC,SACfR,mBAAmBQ,QAAQC,IAAI,CAAC,CAACC,WAAaA,SAASC,IAAI,GAAGC,KAAK;IACrE,MAAMC,QAAQd,WAAWK,WAAW,QAAQC,OAAO;IACnD,qEAAqE;IACrE,IAAIC,aAAa,CAACA,UAAUQ,MAAM,EAAE,OAAO;IAC3C,MAAMC,QAAQT,YACV;QAACJ,MAAM,aAAa,sBAAsB;eAAII;SAAU;KAAE,GAC1D,EAAE;IACN,MAAM,CAACU,OAAOC,OAAO,GAAG,MAAMC,QAAQC,GAAG,CAAC;QACxCZ,QAAQN,MAAMY,UAAUE;QACxBR,QACEN,MAAMY,UAAUE,OAAOb,MAAM,UAAU,MAAML;KAEhD;IACD,OAAOC,oBAAoBkB,OAAOC;AACpC"}
@@ -52,8 +52,19 @@ const LEAD_FUNNEL_HELP = Aglyn.pluginDocsHelp('crmReports', {
52
52
  anchor: '#lead-funnel',
53
53
  excerpt: 'Of the leads this site captured in the period, how many are ' + 'still new, being worked, qualified or unqualified — and the ' + 'reasons the unqualified ones were closed. Placed by when each ' + 'lead was first seen.'
54
54
  });
55
- /** One site's leads first seen in a range — a single-field range, no composite index. */ function leadsBetween(firestore, hostId, from, to) {
56
- return query(collection(firestore, 'hosts', hostId, 'leads'), where('firstSeenAtMs', '>=', from), where('firstSeenAtMs', '<', to));
55
+ /**
56
+ * The leads a scope may see, first seen in a range (AGL-3275).
57
+ *
58
+ * `visibleTo` is the site's tokens under a site, and `null` at the
59
+ * organization level where an org-wide member reads without a clause. The
60
+ * range stays a single-field one; with the scope clause it composes with the
61
+ * `visibleTo` + `firstSeenAtMs` entry in `cloud/firebase-firestore.indexes.json`.
62
+ */ function leadsBetween(firestore, orgId, visibleTo, from, to) {
63
+ return query(collection(firestore, 'orgs', orgId, 'leads'), ...visibleTo ? [
64
+ where('visibleTo', 'array-contains-any', [
65
+ ...visibleTo
66
+ ])
67
+ ] : [], where('firstSeenAtMs', '>=', from), where('firstSeenAtMs', '<', to));
57
68
  }
58
69
  const countOf = (target)=>getCountFromServer(target).then((snapshot)=>snapshot.data().count);
59
70
  /**
@@ -111,8 +122,8 @@ const countOf = (target)=>getCountFromServer(target).then((snapshot)=>snapshot.d
111
122
  const { range } = report;
112
123
  const firestore = useFirestore();
113
124
  const captured = useAggregateRead(()=>Promise.all([
114
- countOf(leadsBetween(firestore, hostId, range.from, range.to)),
115
- countOf(leadsBetween(firestore, hostId, range.previousFrom, range.previousTo))
125
+ countOf(leadsBetween(firestore, report.scope[1], report.tokens, range.from, range.to)),
126
+ countOf(leadsBetween(firestore, report.scope[1], report.tokens, range.previousFrom, range.previousTo))
116
127
  ]).then(([current, previous])=>({
117
128
  current,
118
129
  previous
@@ -123,7 +134,7 @@ const countOf = (target)=>getCountFromServer(target).then((snapshot)=>snapshot.d
123
134
  ], {
124
135
  cacheKey: reportCacheKey(report, `leads:counts:${hostId}`)
125
136
  });
126
- const window = useWindowRead(()=>query(leadsBetween(firestore, hostId, range.from, range.to), orderBy('firstSeenAtMs', 'desc'), limit(LEAD_CEILING + 1)), LEAD_CEILING, [
137
+ const window = useWindowRead(()=>query(leadsBetween(firestore, report.scope[1], report.tokens, range.from, range.to), orderBy('firstSeenAtMs', 'desc'), limit(LEAD_CEILING + 1)), LEAD_CEILING, [
127
138
  firestore,
128
139
  hostId,
129
140
  range
@@ -142,17 +153,14 @@ const countOf = (target)=>getCountFromServer(target).then((snapshot)=>snapshot.d
142
153
  }
143
154
  SiteLeadFunnelCard.displayName = 'SiteLeadFunnelCard';
144
155
  /**
145
- * THE CROSS-SITE FUNNEL (AGL-2634): every site of the organization, one
146
- * window each, totaled.
156
+ * THE ORGANIZATION'S FUNNEL (AGL-2634, AGL-3275): every lead the org holds.
147
157
  *
148
- * The counts are one server aggregate per site per period, summed. The
149
- * windows are one bounded read per site the site card's own query, the
150
- * same ceiling merged newest-seen first and NOT cut again: the funnel
151
- * is a placement of leads by status, and cutting the merged list would
152
- * place a busy site's leads and drop a quiet site's. What the ceiling
153
- * bounds is the read per site, and the caption says so when any site had
154
- * more than its window. Held until the mount's site list has settled, so
155
- * a first round against a partial list is not answered and remembered.
158
+ * This totaled one aggregate and one window PER SITE, because the sites'
159
+ * lead collections were disjoint. They share one collection now, so a sum
160
+ * would count a lead that two brands in a consent group both hold once for
161
+ * each of them and the merge that existed so a busy site could not crowd
162
+ * out a quiet one has nothing left to balance. One aggregate and one
163
+ * bounded read, unscoped, as an org-wide member's reads are.
156
164
  */ function OrgLeadFunnelCard(props) {
157
165
  var _ref, _ref1;
158
166
  const { report } = props;
@@ -163,36 +171,32 @@ SiteLeadFunnelCard.displayName = 'SiteLeadFunnelCard';
163
171
  mount
164
172
  ]);
165
173
  const key = (_ref = hostIds == null ? void 0 : hostIds.join('\n')) != null ? _ref : '';
166
- const captured = useAggregateRead(()=>hostIds ? Promise.all(hostIds.map((hostId)=>Promise.all([
167
- countOf(leadsBetween(firestore, hostId, range.from, range.to)),
168
- countOf(leadsBetween(firestore, hostId, range.previousFrom, range.previousTo))
169
- ]))).then((pairs)=>pairs.reduce((sum, [current, previous])=>({
170
- current: sum.current + current,
171
- previous: sum.previous + previous
172
- }), {
173
- current: 0,
174
- previous: 0
175
- })) : null, [
174
+ /*
175
+ * ONE READ, not one per site (AGL-3275). The sites share a collection now,
176
+ * so a lead two brands in a consent group both hold would be counted once
177
+ * for each of them — and the org's funnel would report more leads than the
178
+ * org has. `null` tokens is the org-wide read that carries no clause.
179
+ */ const captured = useAggregateRead(()=>Promise.all([
180
+ countOf(leadsBetween(firestore, report.scope[1], null, range.from, range.to)),
181
+ countOf(leadsBetween(firestore, report.scope[1], null, range.previousFrom, range.previousTo))
182
+ ]).then(([current, previous])=>({
183
+ current,
184
+ previous
185
+ })), [
176
186
  firestore,
177
- key,
187
+ report.scope,
178
188
  range
179
189
  ], {
180
- cacheKey: reportCacheKey(report, `leads:counts:org:${key}`)
190
+ cacheKey: reportCacheKey(report, 'leads:counts:org')
181
191
  });
182
- const merged = useAggregateRead(()=>hostIds ? Promise.all(hostIds.map((hostId)=>getDocs(query(leadsBetween(firestore, hostId, range.from, range.to), orderBy('firstSeenAtMs', 'desc'), limit(LEAD_CEILING + 1))).then((snapshot)=>ceilingedWindow(snapshot.docs.map((document)=>_extends({}, document.data(), {
183
- $id: `${hostId}/${document.id}`
184
- })), LEAD_CEILING)))).then((windows)=>({
185
- rows: windows.flatMap((window)=>window.rows).sort((a, b)=>{
186
- var _b_firstSeenAtMs, _a_firstSeenAtMs;
187
- return Number((_b_firstSeenAtMs = b['firstSeenAtMs']) != null ? _b_firstSeenAtMs : 0) - Number((_a_firstSeenAtMs = a['firstSeenAtMs']) != null ? _a_firstSeenAtMs : 0);
188
- }),
189
- truncated: windows.some((window)=>window.truncated)
190
- })) : null, [
192
+ const merged = useAggregateRead(()=>getDocs(query(leadsBetween(firestore, report.scope[1], null, range.from, range.to), orderBy('firstSeenAtMs', 'desc'), limit(LEAD_CEILING + 1))).then((snapshot)=>ceilingedWindow(snapshot.docs.map((document)=>_extends({}, document.data(), {
193
+ $id: document.id
194
+ })), LEAD_CEILING)), [
191
195
  firestore,
192
- key,
196
+ report.scope,
193
197
  range
194
198
  ], {
195
- cacheKey: reportCacheKey(report, `leads:window:org:${key}`)
199
+ cacheKey: reportCacheKey(report, 'leads:window:org')
196
200
  });
197
201
  const window = useMemo(()=>{
198
202
  var _ref, _ref1;
@@ -213,8 +217,8 @@ SiteLeadFunnelCard.displayName = 'SiteLeadFunnelCard';
213
217
  subheader: `Every site (${sites.toLocaleString()})`,
214
218
  captured: captured,
215
219
  window: window,
216
- caption: sampled ? `Placed from the ${window.rows.length.toLocaleString()} most recently captured leads` + ` across ${plural(sites, 'site')} — at most ${LEAD_CEILING.toLocaleString()} per site` + (figures !== null ? ` — of ${figures.current.toLocaleString()}` : '') + ' in the period; the captured tile is counted on the server.' : undefined,
217
- errorText: 'Some site’s leads could not be read.'
220
+ caption: sampled ? `Placed from the ${window.rows.length.toLocaleString()} most recently captured leads` + ` — at most ${LEAD_CEILING.toLocaleString()}` + (figures !== null ? ` — of ${figures.current.toLocaleString()}` : '') + ' in the period; the captured tile is counted on the server.' : undefined,
221
+ errorText: 'The organization’s leads could not be read.'
218
222
  });
219
223
  }
220
224
  OrgLeadFunnelCard.displayName = 'OrgLeadFunnelCard';
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/lead-funnel-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport {\n percent,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ceilingedWindow } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { Alert, Button, Stack, Typography } from '@mui/material'\nimport {\n collection,\n type Firestore,\n getCountFromServer,\n getDocs,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useCrmOrgMount } from '../../hooks/use-crm-org-mount'\nimport { ReportBreakdown } from './report-breakdown'\nimport { ReportExport } from './report-export'\nimport { plural, reportFilename } from './report-format'\nimport { type CrmReportScope, reportCacheKey } from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport {\n type AggregateRead,\n useAggregateRead,\n useWindowRead,\n type WindowRead,\n} from './use-aggregate-read'\n\n/**\n * How many of the period's leads the funnel is placed from — the Leads\n * section's own window, over the period rather than over everything.\n *\n * The captured count is a server aggregate and never meets this bound; the\n * funnel needs each lead's status and reason, so it reads the period's\n * newest two hundred and says when there were more. At the organization\n * level the bound is PER SITE (AGL-2634): each site's window is its own,\n * and a site with a thousand leads must not crowd out a site with ten.\n */\nexport const LEAD_CEILING = 200\n/** How many reasons the bar list draws; the rest are counted beneath it. */\nconst REASONS_SHOWN = 8\n\n/** The CSV's columns: one file for both the statuses and the reasons. */\nconst COLUMNS = ['Kind', 'Label', 'Count', 'Share'] as const\n\ntype LeadRow = Record<string, unknown> & Aglyn.CrmLeadFields & { $id: string }\n\n/** The period's captured count, and the previous period's for the delta. */\ninterface CapturedFigures {\n current: number\n previous: number\n}\n\nexport interface LeadFunnelCardProps {\n report: CrmReportScope\n /**\n * The site whose leads are placed. A lead lives under its host, not the\n * org — `hosts/{hostId}/leads`, private by path, no `visibleTo` — so the\n * report's org scope cannot reach it and the card is told the site. At\n * the organization level `null` (AGL-2630): the card then reads every\n * site the mount lists, one window each (AGL-2634).\n */\n hostId: string | null\n}\n\nconst LEAD_FUNNEL_HELP = Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#lead-funnel',\n excerpt:\n 'Of the leads this site captured in the period, how many are ' +\n 'still new, being worked, qualified or unqualified — and the ' +\n 'reasons the unqualified ones were closed. Placed by when each ' +\n 'lead was first seen.',\n})\n\n/** One site's leads first seen in a range — a single-field range, no composite index. */\nfunction leadsBetween(firestore: Firestore, hostId: string, from: number, to: number) {\n return query(\n collection(firestore, 'hosts', hostId, 'leads'),\n where('firstSeenAtMs', '>=', from),\n where('firstSeenAtMs', '<', to),\n )\n}\n\nconst countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n\n/**\n * The lead funnel, at whichever level the report is mounted (AGL-2624,\n * AGL-2630, AGL-2634).\n *\n * Under a site: that site. At the organization level: every site the\n * mount lists, one window each, totaled — the org hub's reports total\n * every site, and the leads are no exception now that the fan-out is\n * bounded by the org's site list. With no sites to read the card says so\n * instead of guessing.\n */\nexport function LeadFunnelCard(props: LeadFunnelCardProps) {\n const { report, hostId } = props\n const mount = useCrmOrgMount()\n if (hostId) return <SiteLeadFunnelCard report={report} hostId={hostId} />\n if (mount && (!mount.hostsReady || mount.hosts.length > 0)) {\n return <OrgLeadFunnelCard report={report} />\n }\n return (\n <CardDisplay header={'Lead funnel'} help={LEAD_FUNNEL_HELP} contentGutterX contentGutterY>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {mount\n ? 'Leads live under a site, and this organization has no sites yet.'\n : 'Leads live under a site, and this report is mounted under none.'}\n </Typography>\n </CardDisplay>\n )\n}\n\n/**\n * The lead funnel (AGL-2624): of the leads this site captured in the\n * period, how many are still new, being worked, qualified or unqualified —\n * and why the unqualified ones were closed.\n *\n * A cohort by first sighting, followed to where each lead stands now,\n * because that is the question a period asks of leads — \"what became of\n * the ones we got\" — and because a lead's status has no history on the\n * document: only its current value. The window is a range on\n * `firstSeenAtMs`, which the capture door stamps once on creation and\n * never again, so a returning visitor's second form is not a second lead\n * in a second period. A single-field range needs no composite index.\n *\n * Read directly off `hosts/{hostId}/leads` under the site's own rules —\n * any member of the site may read it — the way the Leads section reads\n * it, so the funnel and the list can never disagree about who may see a\n * lead.\n */\nfunction SiteLeadFunnelCard(props: { report: CrmReportScope; hostId: string }) {\n const { report, hostId } = props\n const { range } = report\n const firestore = useFirestore()\n\n const captured = useAggregateRead<CapturedFigures>(\n () =>\n Promise.all([\n countOf(leadsBetween(firestore, hostId, range.from, range.to)),\n countOf(leadsBetween(firestore, hostId, range.previousFrom, range.previousTo)),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, hostId, range],\n { cacheKey: reportCacheKey(report, `leads:counts:${hostId}`) },\n )\n const window = useWindowRead<LeadRow>(\n () =>\n query(\n leadsBetween(firestore, hostId, range.from, range.to),\n orderBy('firstSeenAtMs', 'desc'),\n limit(LEAD_CEILING + 1),\n ),\n LEAD_CEILING,\n [firestore, hostId, range],\n { cacheKey: reportCacheKey(report, `leads:window:${hostId}`) },\n )\n const figures = captured.value\n const sampled =\n window.status === 'success' &&\n (window.truncated || (figures !== null && figures.current > window.rows.length))\n return (\n <LeadFunnelBody\n report={report}\n captured={captured}\n window={window}\n caption={\n sampled\n ? `Placed from the ${window.rows.length.toLocaleString()} most recently captured leads` +\n (figures !== null ? ` of ${figures.current.toLocaleString()}` : '') +\n ' in the period; the captured tile is counted on the server.'\n : undefined\n }\n errorText={'This site’s leads could not be read.'}\n />\n )\n}\nSiteLeadFunnelCard.displayName = 'SiteLeadFunnelCard'\n\n/**\n * THE CROSS-SITE FUNNEL (AGL-2634): every site of the organization, one\n * window each, totaled.\n *\n * The counts are one server aggregate per site per period, summed. The\n * windows are one bounded read per site — the site card's own query, the\n * same ceiling — merged newest-seen first and NOT cut again: the funnel\n * is a placement of leads by status, and cutting the merged list would\n * place a busy site's leads and drop a quiet site's. What the ceiling\n * bounds is the read per site, and the caption says so when any site had\n * more than its window. Held until the mount's site list has settled, so\n * a first round against a partial list is not answered and remembered.\n */\nfunction OrgLeadFunnelCard(props: { report: CrmReportScope }) {\n const { report } = props\n const { range } = report\n const firestore = useFirestore()\n const mount = useCrmOrgMount()\n const hostIds = useMemo(\n () => (mount?.hostsReady ? mount.hosts.map((host) => host.id) : null),\n [mount],\n )\n const key = hostIds?.join('\\n') ?? ''\n\n const captured = useAggregateRead<CapturedFigures>(\n () =>\n hostIds\n ? Promise.all(\n hostIds.map((hostId) =>\n Promise.all([\n countOf(leadsBetween(firestore, hostId, range.from, range.to)),\n countOf(leadsBetween(firestore, hostId, range.previousFrom, range.previousTo)),\n ]),\n ),\n ).then((pairs) =>\n pairs.reduce(\n (sum, [current, previous]) => ({\n current: sum.current + current,\n previous: sum.previous + previous,\n }),\n { current: 0, previous: 0 },\n ),\n )\n : null,\n [firestore, key, range],\n { cacheKey: reportCacheKey(report, `leads:counts:org:${key}`) },\n )\n const merged = useAggregateRead<{ rows: LeadRow[]; truncated: boolean }>(\n () =>\n hostIds\n ? Promise.all(\n hostIds.map((hostId) =>\n getDocs(\n query(\n leadsBetween(firestore, hostId, range.from, range.to),\n orderBy('firstSeenAtMs', 'desc'),\n limit(LEAD_CEILING + 1),\n ),\n ).then((snapshot) =>\n ceilingedWindow(\n snapshot.docs.map(\n (document) =>\n ({ ...document.data(), $id: `${hostId}/${document.id}` }) as unknown as LeadRow,\n ),\n LEAD_CEILING,\n ),\n ),\n ),\n ).then((windows) => ({\n rows: windows\n .flatMap((window) => window.rows)\n .sort(\n (a, b) =>\n Number(b['firstSeenAtMs'] ?? 0) - Number(a['firstSeenAtMs'] ?? 0),\n ),\n truncated: windows.some((window) => window.truncated),\n }))\n : null,\n [firestore, key, range],\n { cacheKey: reportCacheKey(report, `leads:window:org:${key}`) },\n )\n const window = useMemo<WindowRead<LeadRow>>(\n () => ({\n rows: merged.value?.rows ?? [],\n truncated: merged.value?.truncated ?? false,\n status: merged.status,\n }),\n [merged],\n )\n const sites = hostIds?.length ?? 0\n const figures = captured.value\n const sampled =\n window.status === 'success' &&\n (window.truncated || (figures !== null && figures.current > window.rows.length))\n return (\n <LeadFunnelBody\n report={report}\n subheader={`Every site (${sites.toLocaleString()})`}\n captured={captured}\n window={window}\n caption={\n sampled\n ? `Placed from the ${window.rows.length.toLocaleString()} most recently captured leads` +\n ` across ${plural(sites, 'site')} — at most ${LEAD_CEILING.toLocaleString()} per site` +\n (figures !== null ? ` — of ${figures.current.toLocaleString()}` : '') +\n ' in the period; the captured tile is counted on the server.'\n : undefined\n }\n errorText={'Some site’s leads could not be read.'}\n />\n )\n}\nOrgLeadFunnelCard.displayName = 'OrgLeadFunnelCard'\n\n/**\n * The funnel as drawn, whichever level fed it: four tiles, the statuses,\n * the reasons, the export. Pure over the two reads.\n */\nfunction LeadFunnelBody(props: {\n report: CrmReportScope\n subheader?: string\n captured: AggregateRead<CapturedFigures>\n window: WindowRead<LeadRow>\n caption?: string\n errorText: string\n}) {\n const { report, subheader, captured, window, caption, errorText } = props\n const { period, routes } = report\n const status = window.status\n\n const funnel = useMemo(() => Aglyn.leadFunnel(window.rows), [window])\n const read = status === 'success'\n const figures = captured.value\n const share = (count: number, of: number): string | null =>\n of > 0 ? percent(count / of) : null\n const reasons = funnel.reasons.slice(0, REASONS_SHOWN)\n const moreReasons = funnel.reasons.length - reasons.length\n\n return (\n <CardDisplay\n header={'Lead funnel'}\n subheader={subheader}\n help={LEAD_FUNNEL_HELP}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.section('leads')}\n size=\"small\"\n color=\"primary\"\n >\n {'Open leads'}\n </Button>\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Leads captured'}\n value={figures ? figures.current.toLocaleString() : null}\n deltaPct={figures ? Aglyn.deltaPercent(figures.current, figures.previous) : null}\n note={'first seen in the period'}\n href={routes.section('leads')}\n />\n <ReportStatTile\n label={'Qualified'}\n value={read ? funnel.byStatus.qualified.toLocaleString() : null}\n note={\n funnel.qualifiedRate === null\n ? read\n ? 'nobody captured in the period'\n : undefined\n : `${percent(funnel.qualifiedRate)} of those captured`\n }\n />\n <ReportStatTile\n label={'Unqualified'}\n value={read ? funnel.byStatus.unqualified.toLocaleString() : null}\n note={\n funnel.unqualifiedRate === null\n ? undefined\n : `${percent(funnel.unqualifiedRate)} of those captured`\n }\n riseIsGood={false}\n />\n <ReportStatTile\n label={'Still open'}\n value={read ? funnel.open.toLocaleString() : null}\n note={'new or being worked'}\n href={routes.section('leads')}\n />\n </Stack>\n {captured.status === 'error' || status === 'error' ? (\n <Alert severity=\"warning\">{errorText}</Alert>\n ) : null}\n <Section title={'Where they stand'}>\n <ReportBreakdown\n rows={Aglyn.CRM_LEAD_STATUSES.map((leadStatus) => ({\n key: leadStatus,\n label: Aglyn.CRM_LEAD_STATUS_LABELS[leadStatus],\n value: funnel.byStatus[leadStatus],\n note: share(funnel.byStatus[leadStatus], funnel.total) ?? undefined,\n color:\n leadStatus === 'qualified'\n ? 'success.main'\n : leadStatus === 'unqualified'\n ? 'warning.main'\n : undefined,\n }))}\n emptyText={''}\n />\n {read && !funnel.total ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No leads captured in this period.'}\n </Typography>\n ) : null}\n </Section>\n <Section title={'Why leads were unqualified'}>\n <ReportBreakdown\n rows={reasons.map((reason) => ({\n key: reason.key,\n label: reason.label,\n value: reason.count,\n note: share(reason.count, funnel.byStatus.unqualified) ?? undefined,\n color: 'warning.main',\n }))}\n emptyText={\n status === 'loading' ? 'Reading…' : 'No lead was unqualified in this period.'\n }\n />\n {moreReasons > 0 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`And ${plural(moreReasons, 'other reason')}, each given once or twice.`}\n </Typography>\n ) : null}\n </Section>\n <ReportExport\n filename={reportFilename('lead-funnel', period)}\n columns={COLUMNS}\n rows={() => [\n ...Aglyn.CRM_LEAD_STATUSES.map((leadStatus) => [\n 'Status',\n Aglyn.CRM_LEAD_STATUS_LABELS[leadStatus],\n funnel.byStatus[leadStatus],\n share(funnel.byStatus[leadStatus], funnel.total) ?? '',\n ]),\n ...funnel.reasons.map((reason) => [\n 'Unqualified reason',\n reason.label,\n reason.count,\n share(reason.count, funnel.byStatus.unqualified) ?? '',\n ]),\n ]}\n disabled={!read || !funnel.total}\n caption={caption}\n />\n </Stack>\n </CardDisplay>\n )\n}\nLeadFunnelBody.displayName = 'LeadFunnelBody'\nLeadFunnelCard.displayName = 'LeadFunnelCard'\n\nexport default LeadFunnelCard\n"],"names":["Aglyn","AppLink","CardDisplay","percent","Section","ceilingedWindow","Alert","Button","Stack","Typography","collection","getCountFromServer","getDocs","limit","orderBy","query","where","useMemo","useFirestore","useCrmOrgMount","ReportBreakdown","ReportExport","plural","reportFilename","reportCacheKey","ReportStatTile","useAggregateRead","useWindowRead","LEAD_CEILING","REASONS_SHOWN","COLUMNS","LEAD_FUNNEL_HELP","pluginDocsHelp","anchor","excerpt","leadsBetween","firestore","hostId","from","to","countOf","target","then","snapshot","data","count","LeadFunnelCard","props","report","mount","SiteLeadFunnelCard","hostsReady","hosts","length","OrgLeadFunnelCard","header","help","contentGutterX","contentGutterY","variant","color","range","captured","Promise","all","previousFrom","previousTo","current","previous","cacheKey","window","figures","value","sampled","status","truncated","rows","LeadFunnelBody","caption","toLocaleString","undefined","errorText","displayName","hostIds","map","host","id","key","join","pairs","reduce","sum","merged","docs","document","$id","windows","flatMap","sort","a","b","Number","some","sites","subheader","period","routes","funnel","leadFunnel","read","share","of","reasons","slice","moreReasons","HeaderProps","action","component","componentVariant","nativeButton","href","section","size","spacing","direction","sx","flexWrap","label","deltaPct","deltaPercent","note","byStatus","qualified","qualifiedRate","unqualified","unqualifiedRate","riseIsGood","open","severity","title","CRM_LEAD_STATUSES","leadStatus","CRM_LEAD_STATUS_LABELS","total","emptyText","reason","filename","columns","disabled"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/components/reports/lead-funnel-card.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport * as Aglyn from '@aglyn/aglyn'\nimport { AppLink, CardDisplay } from '@aglyn/shared-ui-jsx'\nimport {\n percent,\n Section,\n} from '@aglyn/shared-ui-jsx/components/measured-figures.component'\nimport { ceilingedWindow } from '@aglyn/tenant-feature-instance/hooks/host-collection-queries'\nimport { Alert, Button, Stack, Typography } from '@mui/material'\nimport {\n collection,\n type Firestore,\n getCountFromServer,\n getDocs,\n limit,\n orderBy,\n query,\n where,\n} from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useFirestore } from '@aglyn/tenant-feature-instance'\nimport { useCrmOrgMount } from '../../hooks/use-crm-org-mount'\nimport { ReportBreakdown } from './report-breakdown'\nimport { ReportExport } from './report-export'\nimport { plural, reportFilename } from './report-format'\nimport { type CrmReportScope, reportCacheKey } from './report-scope'\nimport { ReportStatTile } from './report-stat-tile'\nimport {\n type AggregateRead,\n useAggregateRead,\n useWindowRead,\n type WindowRead,\n} from './use-aggregate-read'\n\n/**\n * How many of the period's leads the funnel is placed from — the Leads\n * section's own window, over the period rather than over everything.\n *\n * The captured count is a server aggregate and never meets this bound; the\n * funnel needs each lead's status and reason, so it reads the period's\n * newest two hundred and says when there were more. At the organization\n * level the bound is PER SITE (AGL-2634): each site's window is its own,\n * and a site with a thousand leads must not crowd out a site with ten.\n */\nexport const LEAD_CEILING = 200\n/** How many reasons the bar list draws; the rest are counted beneath it. */\nconst REASONS_SHOWN = 8\n\n/** The CSV's columns: one file for both the statuses and the reasons. */\nconst COLUMNS = ['Kind', 'Label', 'Count', 'Share'] as const\n\ntype LeadRow = Record<string, unknown> & Aglyn.CrmLeadFields & { $id: string }\n\n/** The period's captured count, and the previous period's for the delta. */\ninterface CapturedFigures {\n current: number\n previous: number\n}\n\nexport interface LeadFunnelCardProps {\n report: CrmReportScope\n /**\n * The site whose leads are placed. A lead lives under its host, not the\n * org — `hosts/{hostId}/leads`, private by path, no `visibleTo` — so the\n * report's org scope cannot reach it and the card is told the site. At\n * the organization level `null` (AGL-2630): the card then reads every\n * site the mount lists, one window each (AGL-2634).\n */\n hostId: string | null\n}\n\nconst LEAD_FUNNEL_HELP = Aglyn.pluginDocsHelp('crmReports', {\n anchor: '#lead-funnel',\n excerpt:\n 'Of the leads this site captured in the period, how many are ' +\n 'still new, being worked, qualified or unqualified — and the ' +\n 'reasons the unqualified ones were closed. Placed by when each ' +\n 'lead was first seen.',\n})\n\n/**\n * The leads a scope may see, first seen in a range (AGL-3275).\n *\n * `visibleTo` is the site's tokens under a site, and `null` at the\n * organization level where an org-wide member reads without a clause. The\n * range stays a single-field one; with the scope clause it composes with the\n * `visibleTo` + `firstSeenAtMs` entry in `cloud/firebase-firestore.indexes.json`.\n */\nfunction leadsBetween(\n firestore: Firestore,\n orgId: string,\n visibleTo: readonly string[] | null,\n from: number,\n to: number,\n) {\n return query(\n collection(firestore, 'orgs', orgId, 'leads'),\n ...(visibleTo ? [where('visibleTo', 'array-contains-any', [...visibleTo])] : []),\n where('firstSeenAtMs', '>=', from),\n where('firstSeenAtMs', '<', to),\n )\n}\n\nconst countOf = (target: ReturnType<typeof query>) =>\n getCountFromServer(target).then((snapshot) => snapshot.data().count)\n\n/**\n * The lead funnel, at whichever level the report is mounted (AGL-2624,\n * AGL-2630, AGL-2634).\n *\n * Under a site: that site. At the organization level: every site the\n * mount lists, one window each, totaled — the org hub's reports total\n * every site, and the leads are no exception now that the fan-out is\n * bounded by the org's site list. With no sites to read the card says so\n * instead of guessing.\n */\nexport function LeadFunnelCard(props: LeadFunnelCardProps) {\n const { report, hostId } = props\n const mount = useCrmOrgMount()\n if (hostId) return <SiteLeadFunnelCard report={report} hostId={hostId} />\n if (mount && (!mount.hostsReady || mount.hosts.length > 0)) {\n return <OrgLeadFunnelCard report={report} />\n }\n return (\n <CardDisplay header={'Lead funnel'} help={LEAD_FUNNEL_HELP} contentGutterX contentGutterY>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {mount\n ? 'Leads live under a site, and this organization has no sites yet.'\n : 'Leads live under a site, and this report is mounted under none.'}\n </Typography>\n </CardDisplay>\n )\n}\n\n/**\n * The lead funnel (AGL-2624): of the leads this site captured in the\n * period, how many are still new, being worked, qualified or unqualified —\n * and why the unqualified ones were closed.\n *\n * A cohort by first sighting, followed to where each lead stands now,\n * because that is the question a period asks of leads — \"what became of\n * the ones we got\" — and because a lead's status has no history on the\n * document: only its current value. The window is a range on\n * `firstSeenAtMs`, which the capture door stamps once on creation and\n * never again, so a returning visitor's second form is not a second lead\n * in a second period. A single-field range needs no composite index.\n *\n * Read directly off `hosts/{hostId}/leads` under the site's own rules —\n * any member of the site may read it — the way the Leads section reads\n * it, so the funnel and the list can never disagree about who may see a\n * lead.\n */\nfunction SiteLeadFunnelCard(props: { report: CrmReportScope; hostId: string }) {\n const { report, hostId } = props\n const { range } = report\n const firestore = useFirestore()\n\n const captured = useAggregateRead<CapturedFigures>(\n () =>\n Promise.all([\n countOf(leadsBetween(firestore, report.scope[1], report.tokens, range.from, range.to)),\n countOf(\n leadsBetween(firestore, report.scope[1], report.tokens, range.previousFrom, range.previousTo),\n ),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, hostId, range],\n { cacheKey: reportCacheKey(report, `leads:counts:${hostId}`) },\n )\n const window = useWindowRead<LeadRow>(\n () =>\n query(\n leadsBetween(firestore, report.scope[1], report.tokens, range.from, range.to),\n orderBy('firstSeenAtMs', 'desc'),\n limit(LEAD_CEILING + 1),\n ),\n LEAD_CEILING,\n [firestore, hostId, range],\n { cacheKey: reportCacheKey(report, `leads:window:${hostId}`) },\n )\n const figures = captured.value\n const sampled =\n window.status === 'success' &&\n (window.truncated || (figures !== null && figures.current > window.rows.length))\n return (\n <LeadFunnelBody\n report={report}\n captured={captured}\n window={window}\n caption={\n sampled\n ? `Placed from the ${window.rows.length.toLocaleString()} most recently captured leads` +\n (figures !== null ? ` of ${figures.current.toLocaleString()}` : '') +\n ' in the period; the captured tile is counted on the server.'\n : undefined\n }\n errorText={'This site’s leads could not be read.'}\n />\n )\n}\nSiteLeadFunnelCard.displayName = 'SiteLeadFunnelCard'\n\n/**\n * THE ORGANIZATION'S FUNNEL (AGL-2634, AGL-3275): every lead the org holds.\n *\n * This totaled one aggregate and one window PER SITE, because the sites'\n * lead collections were disjoint. They share one collection now, so a sum\n * would count a lead that two brands in a consent group both hold once for\n * each of them — and the merge that existed so a busy site could not crowd\n * out a quiet one has nothing left to balance. One aggregate and one\n * bounded read, unscoped, as an org-wide member's reads are.\n */\nfunction OrgLeadFunnelCard(props: { report: CrmReportScope }) {\n const { report } = props\n const { range } = report\n const firestore = useFirestore()\n const mount = useCrmOrgMount()\n const hostIds = useMemo(\n () => (mount?.hostsReady ? mount.hosts.map((host) => host.id) : null),\n [mount],\n )\n const key = hostIds?.join('\\n') ?? ''\n\n /*\n * ONE READ, not one per site (AGL-3275). The sites share a collection now,\n * so a lead two brands in a consent group both hold would be counted once\n * for each of them — and the org's funnel would report more leads than the\n * org has. `null` tokens is the org-wide read that carries no clause.\n */\n const captured = useAggregateRead<CapturedFigures>(\n () =>\n Promise.all([\n countOf(leadsBetween(firestore, report.scope[1], null, range.from, range.to)),\n countOf(\n leadsBetween(firestore, report.scope[1], null, range.previousFrom, range.previousTo),\n ),\n ]).then(([current, previous]) => ({ current, previous })),\n [firestore, report.scope, range],\n { cacheKey: reportCacheKey(report, 'leads:counts:org') },\n )\n const merged = useAggregateRead<{ rows: LeadRow[]; truncated: boolean }>(\n () =>\n getDocs(\n query(\n leadsBetween(firestore, report.scope[1], null, range.from, range.to),\n orderBy('firstSeenAtMs', 'desc'),\n limit(LEAD_CEILING + 1),\n ),\n ).then((snapshot) =>\n ceilingedWindow(\n snapshot.docs.map(\n (document) => ({ ...document.data(), $id: document.id }) as unknown as LeadRow,\n ),\n LEAD_CEILING,\n ),\n ),\n [firestore, report.scope, range],\n { cacheKey: reportCacheKey(report, 'leads:window:org') },\n )\n const window = useMemo<WindowRead<LeadRow>>(\n () => ({\n rows: merged.value?.rows ?? [],\n truncated: merged.value?.truncated ?? false,\n status: merged.status,\n }),\n [merged],\n )\n const sites = hostIds?.length ?? 0\n const figures = captured.value\n const sampled =\n window.status === 'success' &&\n (window.truncated || (figures !== null && figures.current > window.rows.length))\n return (\n <LeadFunnelBody\n report={report}\n subheader={`Every site (${sites.toLocaleString()})`}\n captured={captured}\n window={window}\n caption={\n sampled\n ? `Placed from the ${window.rows.length.toLocaleString()} most recently captured leads` +\n ` — at most ${LEAD_CEILING.toLocaleString()}` +\n (figures !== null ? ` — of ${figures.current.toLocaleString()}` : '') +\n ' in the period; the captured tile is counted on the server.'\n : undefined\n }\n errorText={'The organization’s leads could not be read.'}\n />\n )\n}\nOrgLeadFunnelCard.displayName = 'OrgLeadFunnelCard'\n\n/**\n * The funnel as drawn, whichever level fed it: four tiles, the statuses,\n * the reasons, the export. Pure over the two reads.\n */\nfunction LeadFunnelBody(props: {\n report: CrmReportScope\n subheader?: string\n captured: AggregateRead<CapturedFigures>\n window: WindowRead<LeadRow>\n caption?: string\n errorText: string\n}) {\n const { report, subheader, captured, window, caption, errorText } = props\n const { period, routes } = report\n const status = window.status\n\n const funnel = useMemo(() => Aglyn.leadFunnel(window.rows), [window])\n const read = status === 'success'\n const figures = captured.value\n const share = (count: number, of: number): string | null =>\n of > 0 ? percent(count / of) : null\n const reasons = funnel.reasons.slice(0, REASONS_SHOWN)\n const moreReasons = funnel.reasons.length - reasons.length\n\n return (\n <CardDisplay\n header={'Lead funnel'}\n subheader={subheader}\n help={LEAD_FUNNEL_HELP}\n contentGutterX\n contentGutterY\n HeaderProps={{\n action: (\n <Button\n component={AppLink as any}\n {...({ componentVariant: 'naked', nativeButton: false } as any)}\n href={routes.section('leads')}\n size=\"small\"\n color=\"primary\"\n >\n {'Open leads'}\n </Button>\n ),\n }}\n >\n <Stack spacing={2}>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap' }}>\n <ReportStatTile\n label={'Leads captured'}\n value={figures ? figures.current.toLocaleString() : null}\n deltaPct={figures ? Aglyn.deltaPercent(figures.current, figures.previous) : null}\n note={'first seen in the period'}\n href={routes.section('leads')}\n />\n <ReportStatTile\n label={'Qualified'}\n value={read ? funnel.byStatus.qualified.toLocaleString() : null}\n note={\n funnel.qualifiedRate === null\n ? read\n ? 'nobody captured in the period'\n : undefined\n : `${percent(funnel.qualifiedRate)} of those captured`\n }\n />\n <ReportStatTile\n label={'Unqualified'}\n value={read ? funnel.byStatus.unqualified.toLocaleString() : null}\n note={\n funnel.unqualifiedRate === null\n ? undefined\n : `${percent(funnel.unqualifiedRate)} of those captured`\n }\n riseIsGood={false}\n />\n <ReportStatTile\n label={'Still open'}\n value={read ? funnel.open.toLocaleString() : null}\n note={'new or being worked'}\n href={routes.section('leads')}\n />\n </Stack>\n {captured.status === 'error' || status === 'error' ? (\n <Alert severity=\"warning\">{errorText}</Alert>\n ) : null}\n <Section title={'Where they stand'}>\n <ReportBreakdown\n rows={Aglyn.CRM_LEAD_STATUSES.map((leadStatus) => ({\n key: leadStatus,\n label: Aglyn.CRM_LEAD_STATUS_LABELS[leadStatus],\n value: funnel.byStatus[leadStatus],\n note: share(funnel.byStatus[leadStatus], funnel.total) ?? undefined,\n color:\n leadStatus === 'qualified'\n ? 'success.main'\n : leadStatus === 'unqualified'\n ? 'warning.main'\n : undefined,\n }))}\n emptyText={''}\n />\n {read && !funnel.total ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No leads captured in this period.'}\n </Typography>\n ) : null}\n </Section>\n <Section title={'Why leads were unqualified'}>\n <ReportBreakdown\n rows={reasons.map((reason) => ({\n key: reason.key,\n label: reason.label,\n value: reason.count,\n note: share(reason.count, funnel.byStatus.unqualified) ?? undefined,\n color: 'warning.main',\n }))}\n emptyText={\n status === 'loading' ? 'Reading…' : 'No lead was unqualified in this period.'\n }\n />\n {moreReasons > 0 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`And ${plural(moreReasons, 'other reason')}, each given once or twice.`}\n </Typography>\n ) : null}\n </Section>\n <ReportExport\n filename={reportFilename('lead-funnel', period)}\n columns={COLUMNS}\n rows={() => [\n ...Aglyn.CRM_LEAD_STATUSES.map((leadStatus) => [\n 'Status',\n Aglyn.CRM_LEAD_STATUS_LABELS[leadStatus],\n funnel.byStatus[leadStatus],\n share(funnel.byStatus[leadStatus], funnel.total) ?? '',\n ]),\n ...funnel.reasons.map((reason) => [\n 'Unqualified reason',\n reason.label,\n reason.count,\n share(reason.count, funnel.byStatus.unqualified) ?? '',\n ]),\n ]}\n disabled={!read || !funnel.total}\n caption={caption}\n />\n </Stack>\n </CardDisplay>\n )\n}\nLeadFunnelBody.displayName = 'LeadFunnelBody'\nLeadFunnelCard.displayName = 'LeadFunnelCard'\n\nexport default LeadFunnelCard\n"],"names":["Aglyn","AppLink","CardDisplay","percent","Section","ceilingedWindow","Alert","Button","Stack","Typography","collection","getCountFromServer","getDocs","limit","orderBy","query","where","useMemo","useFirestore","useCrmOrgMount","ReportBreakdown","ReportExport","plural","reportFilename","reportCacheKey","ReportStatTile","useAggregateRead","useWindowRead","LEAD_CEILING","REASONS_SHOWN","COLUMNS","LEAD_FUNNEL_HELP","pluginDocsHelp","anchor","excerpt","leadsBetween","firestore","orgId","visibleTo","from","to","countOf","target","then","snapshot","data","count","LeadFunnelCard","props","report","hostId","mount","SiteLeadFunnelCard","hostsReady","hosts","length","OrgLeadFunnelCard","header","help","contentGutterX","contentGutterY","variant","color","range","captured","Promise","all","scope","tokens","previousFrom","previousTo","current","previous","cacheKey","window","figures","value","sampled","status","truncated","rows","LeadFunnelBody","caption","toLocaleString","undefined","errorText","displayName","hostIds","map","host","id","key","join","merged","docs","document","$id","sites","subheader","period","routes","funnel","leadFunnel","read","share","of","reasons","slice","moreReasons","HeaderProps","action","component","componentVariant","nativeButton","href","section","size","spacing","direction","sx","flexWrap","label","deltaPct","deltaPercent","note","byStatus","qualified","qualifiedRate","unqualified","unqualifiedRate","riseIsGood","open","severity","title","CRM_LEAD_STATUSES","leadStatus","CRM_LEAD_STATUS_LABELS","total","emptyText","reason","filename","columns","disabled"],"mappings":"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"}
@@ -0,0 +1,29 @@
1
+ import { type CrmActivityLink } from '@aglyn/aglyn';
2
+ import { type CampaignFilingRef } from '../model/campaign-filing-activity';
3
+ /** What one surface's save changed, by campaign, with the names it showed. */
4
+ export interface CampaignFilingChange {
5
+ filed?: readonly CampaignFilingRef[];
6
+ removed?: readonly CampaignFilingRef[];
7
+ }
8
+ /**
9
+ * FILES A MEMBER'S FILING ON THE RECORD'S TIMELINE (AGL-3274), from the
10
+ * console.
11
+ *
12
+ * The lead's Campaigns card and the Leads list's bulk bar write the
13
+ * membership client-direct — a one-field update the rules already admit —
14
+ * so the entry that records it is written the same way, into the org's
15
+ * activity collection the log dialog writes, with the scope a record made
16
+ * on this site carries (`crmScopeTokens`), the signed-in member as author,
17
+ * and the CRM's own id as the filing plugin.
18
+ *
19
+ * Bookkeeping beside the act: the membership has landed by the time this
20
+ * runs, so a write that fails is logged and the filing stands — the card
21
+ * shows the campaigns from the document, not from the timeline.
22
+ */
23
+ export declare function useCampaignFilingLog(input: {
24
+ orgId: string | null | undefined;
25
+ /** The site the entry carries as provenance: the lead's, or the contact's viewing site. */
26
+ hostId: string | null | undefined;
27
+ org: Record<string, unknown> | null | undefined;
28
+ }): (link: CrmActivityLink, change: CampaignFilingChange) => Promise<void>;
29
+ export default useCampaignFilingLog;
@@ -0,0 +1,91 @@
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
+ */ 'use client';
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { CRM_COLLECTIONS, consentGroupForHost, createResourceUid, crmScopeTokens } from "@aglyn/aglyn";
19
+ import { useFirestore, useUser, useUserName } from "@aglyn/tenant-feature-instance";
20
+ import { collection, doc, setDoc } from "firebase/firestore";
21
+ import { useCallback } from "react";
22
+ import { buildCampaignFilingActivity } from "../model/campaign-filing-activity.js";
23
+ /**
24
+ * FILES A MEMBER'S FILING ON THE RECORD'S TIMELINE (AGL-3274), from the
25
+ * console.
26
+ *
27
+ * The lead's Campaigns card and the Leads list's bulk bar write the
28
+ * membership client-direct — a one-field update the rules already admit —
29
+ * so the entry that records it is written the same way, into the org's
30
+ * activity collection the log dialog writes, with the scope a record made
31
+ * on this site carries (`crmScopeTokens`), the signed-in member as author,
32
+ * and the CRM's own id as the filing plugin.
33
+ *
34
+ * Bookkeeping beside the act: the membership has landed by the time this
35
+ * runs, so a write that fails is logged and the filing stands — the card
36
+ * shows the campaigns from the document, not from the timeline.
37
+ */ export function useCampaignFilingLog(input) {
38
+ const { orgId, hostId, org } = input;
39
+ const firestore = useFirestore();
40
+ const { data: user } = useUser();
41
+ const authorName = useUserName();
42
+ const uid = user == null ? void 0 : user.uid;
43
+ return useCallback(async (link, change)=>{
44
+ var _change_filed, _change_removed;
45
+ if (!orgId || !hostId || !uid) return;
46
+ const entries = [
47
+ ...((_change_filed = change.filed) != null ? _change_filed : []).map((campaign)=>({
48
+ action: 'filed',
49
+ campaign
50
+ })),
51
+ ...((_change_removed = change.removed) != null ? _change_removed : []).map((campaign)=>({
52
+ action: 'removed',
53
+ campaign
54
+ }))
55
+ ];
56
+ if (!entries.length) return;
57
+ const visibleTo = crmScopeTokens(org, consentGroupForHost(org, hostId));
58
+ const atMs = Date.now();
59
+ const activities = collection(firestore, 'orgs', orgId, CRM_COLLECTIONS.activities);
60
+ for (const entry of entries){
61
+ try {
62
+ // Named by the platform's own id, as every console resource is.
63
+ await setDoc(doc(activities, createResourceUid()), _extends({}, buildCampaignFilingActivity({
64
+ action: entry.action,
65
+ campaign: entry.campaign,
66
+ link,
67
+ hostId,
68
+ visibleTo,
69
+ atMs,
70
+ byUid: uid,
71
+ byName: authorName || null
72
+ }), {
73
+ createdAt: new Date(),
74
+ updatedAt: new Date()
75
+ }));
76
+ } catch (error) {
77
+ console.error('[crm] the filing could not be written on the timeline', error);
78
+ }
79
+ }
80
+ }, [
81
+ firestore,
82
+ orgId,
83
+ hostId,
84
+ org,
85
+ uid,
86
+ authorName
87
+ ]);
88
+ }
89
+ export default useCampaignFilingLog;
90
+
91
+ //# sourceMappingURL=use-campaign-filing-log.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-campaign-filing-log.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n CRM_COLLECTIONS,\n consentGroupForHost,\n createResourceUid,\n crmScopeTokens,\n type CrmActivityLink,\n} from '@aglyn/aglyn'\nimport { useFirestore, useUser, useUserName } from '@aglyn/tenant-feature-instance'\nimport { collection, doc, setDoc } from 'firebase/firestore'\nimport { useCallback } from 'react'\nimport {\n buildCampaignFilingActivity,\n type CampaignFilingRef,\n} from '../model/campaign-filing-activity'\n\n/** What one surface's save changed, by campaign, with the names it showed. */\nexport interface CampaignFilingChange {\n filed?: readonly CampaignFilingRef[]\n removed?: readonly CampaignFilingRef[]\n}\n\n/**\n * FILES A MEMBER'S FILING ON THE RECORD'S TIMELINE (AGL-3274), from the\n * console.\n *\n * The lead's Campaigns card and the Leads list's bulk bar write the\n * membership client-direct — a one-field update the rules already admit —\n * so the entry that records it is written the same way, into the org's\n * activity collection the log dialog writes, with the scope a record made\n * on this site carries (`crmScopeTokens`), the signed-in member as author,\n * and the CRM's own id as the filing plugin.\n *\n * Bookkeeping beside the act: the membership has landed by the time this\n * runs, so a write that fails is logged and the filing stands — the card\n * shows the campaigns from the document, not from the timeline.\n */\nexport function useCampaignFilingLog(input: {\n orgId: string | null | undefined\n /** The site the entry carries as provenance: the lead's, or the contact's viewing site. */\n hostId: string | null | undefined\n org: Record<string, unknown> | null | undefined\n}) {\n const { orgId, hostId, org } = input\n const firestore = useFirestore()\n const { data: user } = useUser()\n const authorName = useUserName()\n const uid = user?.uid\n\n return useCallback(\n async (link: CrmActivityLink, change: CampaignFilingChange): Promise<void> => {\n if (!orgId || !hostId || !uid) return\n const entries = [\n ...(change.filed ?? []).map((campaign) => ({ action: 'filed' as const, campaign })),\n ...(change.removed ?? []).map((campaign) => ({ action: 'removed' as const, campaign })),\n ]\n if (!entries.length) return\n const visibleTo = crmScopeTokens(org, consentGroupForHost(org, hostId))\n const atMs = Date.now()\n const activities = collection(firestore, 'orgs', orgId, CRM_COLLECTIONS.activities)\n for (const entry of entries) {\n try {\n // Named by the platform's own id, as every console resource is.\n await setDoc(doc(activities, createResourceUid()), {\n ...buildCampaignFilingActivity({\n action: entry.action,\n campaign: entry.campaign,\n link,\n hostId,\n visibleTo,\n atMs,\n byUid: uid,\n byName: authorName || null,\n }),\n createdAt: new Date(),\n updatedAt: new Date(),\n })\n } catch (error) {\n console.error('[crm] the filing could not be written on the timeline', error)\n }\n }\n },\n [firestore, orgId, hostId, org, uid, authorName],\n )\n}\n\nexport default useCampaignFilingLog\n"],"names":["CRM_COLLECTIONS","consentGroupForHost","createResourceUid","crmScopeTokens","useFirestore","useUser","useUserName","collection","doc","setDoc","useCallback","buildCampaignFilingActivity","useCampaignFilingLog","input","orgId","hostId","org","firestore","data","user","authorName","uid","link","change","entries","filed","map","campaign","action","removed","length","visibleTo","atMs","Date","now","activities","entry","byUid","byName","createdAt","updatedAt","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SACEA,eAAe,EACfC,mBAAmB,EACnBC,iBAAiB,EACjBC,cAAc,QAET,eAAc;AACrB,SAASC,YAAY,EAAEC,OAAO,EAAEC,WAAW,QAAQ,iCAAgC;AACnF,SAASC,UAAU,EAAEC,GAAG,EAAEC,MAAM,QAAQ,qBAAoB;AAC5D,SAASC,WAAW,QAAQ,QAAO;AACnC,SACEC,2BAA2B,QAEtB,uCAAmC;AAQ1C;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,qBAAqBC,KAKpC;IACC,MAAM,EAAEC,KAAK,EAAEC,MAAM,EAAEC,GAAG,EAAE,GAAGH;IAC/B,MAAMI,YAAYb;IAClB,MAAM,EAAEc,MAAMC,IAAI,EAAE,GAAGd;IACvB,MAAMe,aAAad;IACnB,MAAMe,MAAMF,wBAAAA,KAAME,GAAG;IAErB,OAAOX,YACL,OAAOY,MAAuBC;YAGtBA,eACAA;QAHN,IAAI,CAACT,SAAS,CAACC,UAAU,CAACM,KAAK;QAC/B,MAAMG,UAAU;eACX,EAACD,gBAAAA,OAAOE,KAAK,YAAZF,gBAAgB,EAAE,EAAEG,GAAG,CAAC,CAACC,WAAc,CAAA;oBAAEC,QAAQ;oBAAkBD;gBAAS,CAAA;eAC7E,EAACJ,kBAAAA,OAAOM,OAAO,YAAdN,kBAAkB,EAAE,EAAEG,GAAG,CAAC,CAACC,WAAc,CAAA;oBAAEC,QAAQ;oBAAoBD;gBAAS,CAAA;SACrF;QACD,IAAI,CAACH,QAAQM,MAAM,EAAE;QACrB,MAAMC,YAAY5B,eAAea,KAAKf,oBAAoBe,KAAKD;QAC/D,MAAMiB,OAAOC,KAAKC,GAAG;QACrB,MAAMC,aAAa5B,WAAWU,WAAW,QAAQH,OAAOd,gBAAgBmC,UAAU;QAClF,KAAK,MAAMC,SAASZ,QAAS;YAC3B,IAAI;gBACF,gEAAgE;gBAChE,MAAMf,OAAOD,IAAI2B,YAAYjC,sBAAsB,aAC9CS,4BAA4B;oBAC7BiB,QAAQQ,MAAMR,MAAM;oBACpBD,UAAUS,MAAMT,QAAQ;oBACxBL;oBACAP;oBACAgB;oBACAC;oBACAK,OAAOhB;oBACPiB,QAAQlB,cAAc;gBACxB;oBACAmB,WAAW,IAAIN;oBACfO,WAAW,IAAIP;;YAEnB,EAAE,OAAOQ,OAAO;gBACdC,QAAQD,KAAK,CAAC,yDAAyDA;YACzE;QACF;IACF,GACA;QAACxB;QAAWH;QAAOC;QAAQC;QAAKK;QAAKD;KAAW;AAEpD;AAEA,eAAeR,qBAAoB"}