@aglyn/plugins-crm 1.0.0-beta.199 → 1.0.0-beta.200

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 (105) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/companies-section.d.ts +6 -6
  3. package/src/lib/components/companies-section.js +54 -84
  4. package/src/lib/components/companies-section.js.map +1 -1
  5. package/src/lib/components/company-edit-drawer.js +17 -4
  6. package/src/lib/components/company-edit-drawer.js.map +1 -1
  7. package/src/lib/components/company-picker.js +6 -2
  8. package/src/lib/components/company-picker.js.map +1 -1
  9. package/src/lib/components/contact-detail-page.js +14 -2
  10. package/src/lib/components/contact-detail-page.js.map +1 -1
  11. package/src/lib/components/contact-list-columns.d.ts +3 -5
  12. package/src/lib/components/contact-list-columns.js +6 -8
  13. package/src/lib/components/contact-list-columns.js.map +1 -1
  14. package/src/lib/components/contacts-bulk-bar.js +10 -1
  15. package/src/lib/components/contacts-bulk-bar.js.map +1 -1
  16. package/src/lib/components/contacts-section.js +115 -163
  17. package/src/lib/components/contacts-section.js.map +1 -1
  18. package/src/lib/components/crm-next-activity-column.d.ts +4 -2
  19. package/src/lib/components/crm-next-activity-column.js +6 -3
  20. package/src/lib/components/crm-next-activity-column.js.map +1 -1
  21. package/src/lib/components/deal-edit-drawer.js +8 -4
  22. package/src/lib/components/deal-edit-drawer.js.map +1 -1
  23. package/src/lib/components/deals-section.d.ts +2 -2
  24. package/src/lib/components/deals-section.js +59 -84
  25. package/src/lib/components/deals-section.js.map +1 -1
  26. package/src/lib/components/lead-campaigns-card.js +11 -3
  27. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  28. package/src/lib/components/lead-properties-card.js +9 -1
  29. package/src/lib/components/lead-properties-card.js.map +1 -1
  30. package/src/lib/components/leads-bulk-bar.js +16 -3
  31. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  32. package/src/lib/components/leads-section.d.ts +2 -1
  33. package/src/lib/components/leads-section.js +110 -148
  34. package/src/lib/components/leads-section.js.map +1 -1
  35. package/src/lib/components/tasks-section.js +128 -121
  36. package/src/lib/components/tasks-section.js.map +1 -1
  37. package/src/lib/constants/company-filters.d.ts +13 -0
  38. package/src/lib/constants/company-filters.js +47 -17
  39. package/src/lib/constants/company-filters.js.map +1 -1
  40. package/src/lib/constants/contact-filters.d.ts +61 -10
  41. package/src/lib/constants/contact-filters.js +373 -141
  42. package/src/lib/constants/contact-filters.js.map +1 -1
  43. package/src/lib/constants/deal-filters.d.ts +44 -0
  44. package/src/lib/constants/deal-filters.js +78 -0
  45. package/src/lib/constants/deal-filters.js.map +1 -0
  46. package/src/lib/hooks/use-crm-list-query.d.ts +68 -0
  47. package/src/lib/hooks/use-crm-list-query.js +182 -0
  48. package/src/lib/hooks/use-crm-list-query.js.map +1 -0
  49. package/src/lib/hooks/use-crm-tasks.d.ts +23 -6
  50. package/src/lib/hooks/use-crm-tasks.js +44 -42
  51. package/src/lib/hooks/use-crm-tasks.js.map +1 -1
  52. package/src/lib/hooks/use-deals.d.ts +2 -34
  53. package/src/lib/hooks/use-deals.js +3 -62
  54. package/src/lib/hooks/use-deals.js.map +1 -1
  55. package/src/lib/model/crm-list-query.d.ts +91 -0
  56. package/src/lib/model/crm-list-query.js +182 -0
  57. package/src/lib/model/crm-list-query.js.map +1 -0
  58. package/src/lib/model/lead-filters.d.ts +49 -43
  59. package/src/lib/model/lead-filters.js +194 -88
  60. package/src/lib/model/lead-filters.js.map +1 -1
  61. package/src/lib/model/list-fields-api.d.ts +26 -0
  62. package/src/lib/model/list-fields-api.js +47 -0
  63. package/src/lib/model/list-fields-api.js.map +1 -0
  64. package/src/lib/model/list-fields.d.ts +47 -0
  65. package/src/lib/model/list-fields.js +39 -0
  66. package/src/lib/model/list-fields.js.map +1 -0
  67. package/src/lib/model/task-views.d.ts +15 -10
  68. package/src/lib/model/task-views.js +68 -9
  69. package/src/lib/model/task-views.js.map +1 -1
  70. package/src/lib/server/companies-import.js +3 -3
  71. package/src/lib/server/companies-import.js.map +1 -1
  72. package/src/lib/server/company-delete.js +3 -1
  73. package/src/lib/server/company-delete.js.map +1 -1
  74. package/src/lib/server/consent-group-participant.js +4 -0
  75. package/src/lib/server/consent-group-participant.js.map +1 -1
  76. package/src/lib/server/contact-update.js +14 -8
  77. package/src/lib/server/contact-update.js.map +1 -1
  78. package/src/lib/server/contacts-import.js +4 -2
  79. package/src/lib/server/contacts-import.js.map +1 -1
  80. package/src/lib/server/deals-import.js +4 -2
  81. package/src/lib/server/deals-import.js.map +1 -1
  82. package/src/lib/server/erase-person.js +2 -1
  83. package/src/lib/server/erase-person.js.map +1 -1
  84. package/src/lib/server/lead-create.js +4 -1
  85. package/src/lib/server/lead-create.js.map +1 -1
  86. package/src/lib/server/lead-source-values.js +9 -5
  87. package/src/lib/server/lead-source-values.js.map +1 -1
  88. package/src/lib/server/leads-import.js +4 -1
  89. package/src/lib/server/leads-import.js.map +1 -1
  90. package/src/lib/server/list-fields-routes.d.ts +29 -0
  91. package/src/lib/server/list-fields-routes.js +85 -0
  92. package/src/lib/server/list-fields-routes.js.map +1 -0
  93. package/src/lib/server/record-email-state.js +5 -2
  94. package/src/lib/server/record-email-state.js.map +1 -1
  95. package/src/lib/server/record-timeline.js +3 -2
  96. package/src/lib/server/record-timeline.js.map +1 -1
  97. package/src/lib/server/task-routes.js +6 -3
  98. package/src/lib/server/task-routes.js.map +1 -1
  99. package/src/lib/server/tasks-import.js +5 -2
  100. package/src/lib/server/tasks-import.js.map +1 -1
  101. package/src/lib/server.js +10 -1
  102. package/src/lib/server.js.map +1 -1
  103. package/src/lib/hooks/use-org-leads.d.ts +0 -50
  104. package/src/lib/hooks/use-org-leads.js +0 -104
  105. package/src/lib/hooks/use-org-leads.js.map +0 -1
@@ -18,12 +18,16 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
19
  import * as Aglyn from "@aglyn/aglyn";
20
20
  import { CONTACT_LIFECYCLE_STAGE_LABELS, CONTACT_LIFECYCLE_STAGES, CONTACT_SOURCE_LABELS, CRM_CONTACT_VIEW_FIELDS, crmContactCustomColumn, crmViewFiltersFromSegment, crmViewSegmentFilters, newResourceScopeFields, ORG_SCOPE_TOKEN, pluginDocsHelp } from "@aglyn/aglyn";
21
- import { hiddenFilterVisibility, matchListFilter } from "@aglyn/shared-ui-jsx/const/list-filter";
21
+ import { hiddenFilterVisibility } from "@aglyn/shared-ui-jsx/const/list-filter";
22
22
  import { ListTable } from "@aglyn/shared-ui-jsx/components/list-table.component";
23
- import { CONTACT_LIST_FILTER_FIELDS, CONTACT_LIST_FILTER_HEADERS, contactCustomFilterFields, contactCustomFilterHeaders } from "../constants/contact-filters.js";
23
+ import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
24
+ import ListQueryNotices from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
25
+ import { CONTACT_LIST_DECLARATION, CONTACT_LIST_FILTER_FIELDS, CONTACT_LIST_FILTER_HEADERS, CONTACT_PREFIX_SEARCH, contactClauseImpliesScope, contactSoloClause, contactCustomFilterFields, contactCustomFilterHeaders, contactQueryClause } from "../constants/contact-filters.js";
26
+ import { crmAskClauses, crmQueryRefusals } from "../model/crm-list-query.js";
27
+ import { useCrmFoldsScope, useCrmListQuery } from "../hooks/use-crm-list-query.js";
24
28
  import { contactPrimaryGroup, contactRecordFromDoc } from "../model/contact-record.js";
25
29
  import { useCrmOrgMount } from "../hooks/use-crm-org-mount.js";
26
- import { crmVisibleToClause, useCrmScope } from "../hooks/use-crm-scope.js";
30
+ import { useCrmScope } from "../hooks/use-crm-scope.js";
27
31
  import { contactsListSeed } from "../model/contacts-list-seed.js";
28
32
  import { crmRoutes } from "../model/crm-routes.js";
29
33
  import { contactsCsv, downloadTextFile } from "../model/contacts-csv.js";
@@ -33,7 +37,7 @@ import EmptyStateComponent from "@aglyn/shared-ui-jsx/components/empty-state.com
33
37
  import ContactsBulkBar from "./contacts-bulk-bar.js";
34
38
  import { ContactImportButton } from "./contact-import-drawer.js";
35
39
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
36
- import { listFilterPlan, useFirestore, useFirestoreCollection, useFirestoreDoc, useUser } from "@aglyn/tenant-feature-instance";
40
+ import { useFirestore, useFirestoreCollection, useFirestoreDoc, useUser } from "@aglyn/tenant-feature-instance";
37
41
  import { Alert, Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack, TextField, Typography } from "@mui/material";
38
42
  import { addDoc, collection, deleteDoc, doc, limit, orderBy, query } from "firebase/firestore";
39
43
  import { useRouter, useSearchParams } from "next/navigation";
@@ -50,7 +54,7 @@ import { useCrmViewGrid } from "../hooks/use-crm-view-grid.js";
50
54
  import { CRM_LIST_SLOTS, CrmColumnOrderProvider } from "./crm-column-menu.js";
51
55
  import { useCompanyOptions } from "./company-picker.js";
52
56
  import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
53
- import { listFilterGridColumns, listRowMatchesSearch } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
57
+ import { listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
54
58
  import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
55
59
  import CrmViewsControl from "./crm-views-control.js";
56
60
  import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-suite-lock.js";
@@ -60,13 +64,6 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
60
64
  * The map lives beside the `ContactSource` union so the dynamic-list rule
61
65
  * editor and this filter cannot disagree about what `order` is called.
62
66
  */ const SOURCE_LABELS = CONTACT_SOURCE_LABELS;
63
- /** What the grid's quick search reads on a contact: who they are, and their tags. */ const CONTACT_SEARCH_FIELDS = [
64
- 'name',
65
- 'email',
66
- 'phone',
67
- 'company',
68
- 'tags'
69
- ];
70
67
  /**
71
68
  * What the list keeps out of sight until a view says otherwise: the
72
69
  * filter-only columns, which never show, and the optional columns
@@ -108,8 +105,7 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
108
105
  * body is the v1 page unchanged, and it takes the shell's full prop bag so
109
106
  * nothing the v1 page read has to be threaded through the hub by name.
110
107
  */ export function ContactsPeopleSection(props) {
111
- var _ref, _ref1, _ref2, _records_contactsCount, _ref3, _ref4, _ref5, _ref6, _ref7, _filterHeaders_windowClauses__field, _filterHeaders_plan_served_field;
112
- var _plan_served;
108
+ var _ref, _ref1, _ref2, _ref3, _records_contactsCount, _ref4, _ref5, _ref6, _ref7;
113
109
  const { hostId, org, releaseFlag, basePath } = props;
114
110
  /*
115
111
  * A row is a LINK now (AGL-2596): opening a person navigates to their own
@@ -211,7 +207,9 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
211
207
  ...seed.filter ? [
212
208
  seed.filter
213
209
  ] : [],
214
- ...seed.source ? [
210
+ // A form's captures all came in through a form: the source beside it
211
+ // would ask a second array clause of the one query (AGL-3321).
212
+ ...seed.source && !seed.formId ? [
215
213
  {
216
214
  field: CRM_CONTACT_VIEW_FIELDS.source,
217
215
  op: 'equals',
@@ -247,97 +245,47 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
247
245
  customFields.active
248
246
  ]);
249
247
  /*
250
- * ONE clause on the query, the rest over the window.
248
+ * EVERY CLAUSE AND THE SEARCH ON ONE QUERY (AGL-3321).
251
249
  *
252
- * Firestore takes one array clause per query and composes the rest only
253
- * through indexes nobody built for every pair of columns, so the first
254
- * clause the translator can serve is the one that reaches every contact
255
- * and every other narrows the thousand the query returned. The caption
256
- * under the bar says which is which; the chips mark the served one.
257
- */ /*
258
- * Under a site the listener already carries the `visibleTo`
259
- * `array-contains-any`, so the translator serves no second array clause
260
- * beside it and keeps an `in` inside the disjunction budget the two share
261
- * (AGL-3321). `formIds` is exempt: its listener drops the scope clause.
262
- */ const plan = useMemo(()=>listFilterPlan(filterFields, views.state.filters, visibleToTokens ? {
263
- arrayScope: {
264
- values: visibleToTokens.length,
265
- exempt: [
266
- 'formIds'
267
- ]
268
- }
269
- } : {}), [
270
- filterFields,
250
+ * Each stored clause is asked through the field its writers keep
251
+ * (`contactQueryClause`): a holder's owner, stage, source, company, tags,
252
+ * custom value or "has bought" as that holder's facet key, the verdict as
253
+ * `emailStatus`, the rest as stored. The query pages the list newest
254
+ * change first and carries all of it; what one query cannot hold is
255
+ * refused by name above the list, and nothing is matched over the rows a
256
+ * page happened to load. Under a site the scope clause is the query's one
257
+ * array clause — the search folds into it, and a facet or form clause
258
+ * stands in its place, for a reader whose access is not limited to
259
+ * particular sites.
260
+ */ const foldsScope = useCrmFoldsScope(orgId, visibleToTokens);
261
+ const groupId = (_ref3 = consentGroup == null ? void 0 : consentGroup.groupId) != null ? _ref3 : null;
262
+ const asked = useMemo(()=>crmAskClauses(views.state.filters, (clause)=>contactQueryClause(clause, {
263
+ groupId,
264
+ foldsScope
265
+ })), [
271
266
  views.state.filters,
272
- visibleToTokens
267
+ groupId,
268
+ foldsScope
273
269
  ]);
274
- const filter = plan.served;
275
- /**
276
- * The one filter that cannot carry the scope clause.
277
- *
278
- * `formIds` is an `array-contains` on the mirror, and Firestore takes one
279
- * array clause per query — so this listener drops its `visibleTo`
280
- * predicate for it, exactly as the company contacts card does for
281
- * `companyIds`, and the rules then admit the read to an org-wide member
282
- * only. A scoped member's listener errors instead of listing, and the
283
- * alert below says so rather than showing an empty form.
284
- */ const byForm = (filter == null ? void 0 : filter.field) === 'formIds';
285
- const { data: contactDocs, status: contactsStatus, /**
286
- * The rows the profile drawer is seeded from are unconfirmed by the
287
- * server (AGL-1358). This payload is narrower than most sites in this
288
- * issue — `email`, `sources` and `interactions` are not in it — but the
289
- * two fields that are, `tags` and `notes`, are BOTH written on every
290
- * save and both come off the seed. Edit the notes against a cached read
291
- * and the tags go back with them, and tags are what
292
- * `contactMatchesSegment` runs on: a saved segment is a campaign
293
- * audience, so a rollback here silently changes who gets emailed.
294
- */ fromCache: contactsFromCache } = useFirestoreCollection(()=>{
295
- if (!dataScope) return null;
296
- /*
297
- * ORDERED, and filtered by the QUERY (AGL-2501, AGL-2292).
298
- *
299
- * Two bugs shared this one line. `limit(1000)` with no `orderBy` returns
300
- * documents in ID order — contacts are created with `.add()` and
301
- * `createResourceUid()`, so that is a pseudo-random SAMPLE of a thousand,
302
- * and the client `.sort()` below made it look reliably newest-first. An
303
- * org with forty thousand contacts saw a thousand arbitrary ones,
304
- * convincingly sorted.
305
- *
306
- * The search then ran over that sample, so a name on the wrong side of
307
- * the cap answered "no contacts match" — the answer a search must never
308
- * give wrongly, on the list a merchant uses to find one person.
309
- *
310
- * The cap STAYS: nobody needs forty thousand rows streamed into a table,
311
- * and the head-count has been a server aggregate since AGL-1706. What
312
- * changes is that the thousand are now the newest thousand, and that a
313
- * filter reaches the whole collection before the cap applies.
314
- */ const constraints = plan.constraints;
315
- /*
316
- * SCOPED, and this is the leak it closes.
317
- *
318
- * The listener had no `where()` at all: `hostId` reached it only to
319
- * resolve which ORG owns the data, so every site in the account listed
320
- * every contact in the account. An agency's client opened Contacts and
321
- * read the other clients' customers.
322
- *
323
- * `array-contains-any` over the group's tokens is the same predicate
324
- * the rules evaluate with `hasAny`, so a filtered query is provable
325
- * per-document and an UNFILTERED one is now permission-denied rather
326
- * than quietly returning everything.
327
- */ return query(collection(firestore, dataScope[0], dataScope[1], 'contacts'), // Dropped for the form mirror alone — see `byForm` — and absent at
328
- // the organization level, where the tokens are `null` (AGL-2630).
329
- ...byForm ? [] : crmVisibleToClause(visibleToTokens), ...constraints != null ? constraints : [
330
- orderBy('updatedAt', 'desc')
331
- ], limit(1000));
332
- }, [
333
- firestore,
334
- dataScope,
335
- plan.constraints,
336
- byForm,
337
- visibleToTokens
338
- ], {
339
- idField: '$id'
270
+ const impliesScope = useMemo(()=>contactClauseImpliesScope(groupId), [
271
+ groupId
272
+ ]);
273
+ // The quick search is the grid's box, answered by the same query.
274
+ const [searchWords, setSearchWords] = useState([]);
275
+ const paged = useCrmListQuery({
276
+ scope: dataScope,
277
+ collection: 'contacts',
278
+ visibleTo: visibleToTokens,
279
+ foldsScope,
280
+ declaration: CONTACT_LIST_DECLARATION,
281
+ clauses: asked.clauses,
282
+ search: searchWords,
283
+ impliesScope,
284
+ soloClause: contactSoloClause,
285
+ prefixSearch: CONTACT_PREFIX_SEARCH
340
286
  });
287
+ const contactDocs = paged.rows;
288
+ const contactsStatus = paged.status;
341
289
  /*
342
290
  * OPENED FOR ONE ADDRESS: move straight on to the record (AGL-2612).
343
291
  *
@@ -353,15 +301,17 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
353
301
  useEffect(()=>{
354
302
  if (openedByEmail.current || !seed.openEmail) return;
355
303
  if (contactsStatus !== 'success') return;
356
- if ((filter == null ? void 0 : filter.field) !== 'email' || filter.value !== seed.openEmail) return;
357
- const only = (contactDocs != null ? contactDocs : []).length === 1 ? contactDocs == null ? void 0 : contactDocs[0] : null;
304
+ const served = paged.plan.served.some((clause)=>clause.field === 'email' && clause.op === 'equals' && clause.value === seed.openEmail);
305
+ if (!served) return;
306
+ const only = contactDocs.length === 1 && !paged.hasMore ? contactDocs[0] : null;
358
307
  if (!only) return;
359
308
  openedByEmail.current = true;
360
309
  router.replace(routes.contact(String(only.$id)));
361
310
  }, [
362
311
  seed.openEmail,
363
312
  contactsStatus,
364
- filter,
313
+ paged.plan,
314
+ paged.hasMore,
365
315
  contactDocs,
366
316
  router,
367
317
  routes
@@ -377,7 +327,7 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
377
327
  * The canonical `name` is the fallback and the shared identity; a holder's
378
328
  * own override wins, so one business renaming a person cannot change what
379
329
  * an unrelated business sees.
380
- */ const contacts = useMemo(()=>(contactDocs != null ? contactDocs : []).map((row)=>contactRecordFromDoc(row, consentGroup != null ? consentGroup : contactPrimaryGroup(row, org))), [
330
+ */ const contacts = useMemo(()=>contactDocs.map((row)=>contactRecordFromDoc(row, consentGroup != null ? consentGroup : contactPrimaryGroup(row, org))), [
381
331
  contactDocs,
382
332
  consentGroup,
383
333
  org
@@ -399,10 +349,10 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
399
349
  /**
400
350
  * The HEAD-COUNT, read as a server-side aggregate (AGL-1706).
401
351
  *
402
- * The listener above is `limit(1000)` and always will be — nobody needs
403
- * 40,000 rows streamed into a table. What it must not do is answer "how
404
- * many contacts does this org have", and it did: `contacts.length`
405
- * saturated at 1,000, which is *exactly* the smallest paid included band
352
+ * The list above reads a page at a time (AGL-3321) — nobody needs 40,000
353
+ * rows streamed into a table. What it must not do is answer "how many
354
+ * contacts does this org have", and it did when it was a `limit(1000)`
355
+ * window: `contacts.length` saturated at 1,000, which is *exactly* the smallest paid included band
406
356
  * (`starter`). So `overageContacts = max(0, used − included)` was 0 on
407
357
  * every stock plan and the alert below could not render at all.
408
358
  *
@@ -450,7 +400,7 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
450
400
  firestore,
451
401
  hostId
452
402
  ]);
453
- const droppedTotal = Number((_ref3 = droppedCounter == null ? void 0 : droppedCounter['total']) != null ? _ref3 : 0);
403
+ const droppedTotal = Number((_ref4 = droppedCounter == null ? void 0 : droppedCounter['total']) != null ? _ref4 : 0);
454
404
  /*==========================================
455
405
  * REFUNDS THAT REACHED NO CONTACT (AGL-2329).
456
406
  *
@@ -470,10 +420,12 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
470
420
  firestore,
471
421
  hostId
472
422
  ]);
473
- const unmatchedRefundTotal = Number((_ref4 = unmatchedRefundCounter == null ? void 0 : unmatchedRefundCounter['total']) != null ? _ref4 : 0);
474
- const unmatchedRefundReason = String((_ref5 = unmatchedRefundCounter == null ? void 0 : unmatchedRefundCounter['lastReason']) != null ? _ref5 : '');
423
+ const unmatchedRefundTotal = Number((_ref5 = unmatchedRefundCounter == null ? void 0 : unmatchedRefundCounter['total']) != null ? _ref5 : 0);
424
+ const unmatchedRefundReason = String((_ref6 = unmatchedRefundCounter == null ? void 0 : unmatchedRefundCounter['lastReason']) != null ? _ref6 : '');
475
425
  // Saved segments (AGL-199): reusable audience filters.
476
- const { data: segmentDocs } = useFirestoreCollection(()=>dataScope ? query(collection(firestore, dataScope[0], dataScope[1], 'contactSegments'), limit(50)) : null, [
426
+ const { data: segmentDocs } = useFirestoreCollection(()=>dataScope ? query(collection(firestore, dataScope[0], dataScope[1], 'contactSegments'), // The first fifty by name — the menu's own order — rather than
427
+ // whichever fifty the ids happen to sort first.
428
+ orderBy('name'), limit(50)) : null, [
477
429
  firestore,
478
430
  dataScope
479
431
  ], {
@@ -510,32 +462,7 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
510
462
  dataScope,
511
463
  firestore
512
464
  ]);
513
- /*
514
- * THE WINDOW CLAUSES narrow in the browser, and the caption says so.
515
- *
516
- * An owner, a stage, a source, a company and a custom value all live on
517
- * the viewing group's FACET, and a facet path is per group, so none of
518
- * them is a field the query grammar can reach without an index per
519
- * group; a second tag clause, or "any of these tags", is an array clause
520
- * the scope predicate already occupies. So every clause the plan could
521
- * not serve refines the ordered window rather than the collection —
522
- * through the same matcher the grammar declares, so a chip and the rows
523
- * under it agree. Ordinary orgs load their whole list into the window
524
- * anyway; the caption below says what these narrow for the ones that do
525
- * not.
526
- */ const uid = (_ref6 = user == null ? void 0 : user.uid) != null ? _ref6 : '';
527
- const windowClauses = plan.window;
528
- // The quick search is the grid's box, answered here over the same window.
529
- const [searchWords, setSearchWords] = useState([]);
530
- const searchKey = searchWords.join(' ');
531
- const visible = useMemo(()=>windowClauses.length || searchWords.length ? contacts.filter((contact)=>windowClauses.every((clause)=>matchListFilter(contact, filterFields, clause)) && listRowMatchesSearch(contact, CONTACT_SEARCH_FIELDS, searchWords)) : contacts, // `searchKey` stands for the words, which are a new array each render.
532
- // eslint-disable-next-line react-hooks/exhaustive-deps
533
- [
534
- contacts,
535
- windowClauses,
536
- filterFields,
537
- searchKey
538
- ]);
465
+ const uid = (_ref7 = user == null ? void 0 : user.uid) != null ? _ref7 : '';
539
466
  /*
540
467
  * The choices the filter bar picks from. The companies are read only once
541
468
  * the reader has reached for a filter or is looking at a view that names
@@ -595,6 +522,18 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
595
522
  companies.options,
596
523
  customFields.active
597
524
  ]);
525
+ // What the query could not hold, named by the clause the reader set.
526
+ const refused = useMemo(()=>crmQueryRefusals(paged.plan, asked, {
527
+ fields: filterFields,
528
+ headers: filterHeaders,
529
+ options: filterOptions
530
+ }), [
531
+ paged.plan,
532
+ asked,
533
+ filterFields,
534
+ filterHeaders,
535
+ filterOptions
536
+ ]);
598
537
  /* One row grammar, the console's (AGL-2501) — the same table everywhere. */ const contactColumns = useMemo(()=>[
599
538
  ...contactListColumns({
600
539
  memberName: members.memberName,
@@ -688,6 +627,12 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
688
627
  enqueueSnackbar
689
628
  ]);
690
629
  /** The rows ticked for a bulk action (AGL-2603); the bar above the table acts on them. */ const [selectedIds, setSelectedIds] = useState([]);
630
+ // A filter, a search or a page is a different set of rows; a selection
631
+ // made on the last one would be a count over rows no longer on screen.
632
+ useEffect(()=>setSelectedIds([]), [
633
+ paged.plan,
634
+ paged.page
635
+ ]);
691
636
  /*==========================================
692
637
  * ADDING ONE PERSON BY HAND (AGL-2596).
693
638
  *
@@ -778,9 +723,9 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
778
723
  customFields.active
779
724
  ]);
780
725
  const handleExport = useCallback(()=>{
781
- downloadTextFile('contacts.csv', 'text/csv', contactsCsv(visible, csvOptions));
726
+ downloadTextFile('contacts.csv', 'text/csv', contactsCsv(contacts, csvOptions));
782
727
  }, [
783
- visible,
728
+ contacts,
784
729
  csvOptions
785
730
  ]);
786
731
  return /*#__PURE__*/ _jsxs(_Fragment, {
@@ -821,7 +766,7 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
821
766
  /*#__PURE__*/ _jsx(Button, {
822
767
  size: "small",
823
768
  onClick: handleExport,
824
- disabled: !visible.length,
769
+ disabled: !contacts.length,
825
770
  children: 'Export CSV'
826
771
  }),
827
772
  suiteIncluded ? /*#__PURE__*/ _jsx(Button, {
@@ -860,7 +805,7 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
860
805
  clauses: views.state.filters,
861
806
  onChange: views.setFilters,
862
807
  options: filterOptions,
863
- servedField: (_ref7 = (_plan_served = plan.served) == null ? void 0 : _plan_served.field) != null ? _ref7 : null
808
+ marksServed: false
864
809
  })
865
810
  ]
866
811
  }),
@@ -898,10 +843,11 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
898
843
  severity: "warning",
899
844
  children: `${unmatchedRefundTotal.toLocaleString()} refund${unmatchedRefundTotal === 1 ? '' : 's'} could not be recorded against a contact${UNMATCHED_REFUND_REASON[unmatchedRefundReason] ? ` — most recently because ${UNMATCHED_REFUND_REASON[unmatchedRefundReason]}` : ''}. The money moved; the customer's timeline does not show it.`
900
845
  }) : null,
901
- byForm && contactsStatus === 'error' ? /*#__PURE__*/ _jsx(Alert, {
902
- severity: "info",
903
- children: 'The contacts this form captured could not be listed. Your ' + 'access is limited to specific sites, and a form filter ' + 'cannot be narrowed to them — an organization administrator ' + 'can see it.'
904
- }) : contacts.length === 0 && !views.state.filters.length ? /*#__PURE__*/ _jsx(EmptyStateComponent, {
846
+ /*#__PURE__*/ _jsx(ListQueryNotices, {
847
+ refused: refused,
848
+ notices: paged.plan.notices
849
+ }),
850
+ contacts.length === 0 && !views.state.filters.length && !searchWords.some((word)=>word.trim()) ? /*#__PURE__*/ _jsx(EmptyStateComponent, {
905
851
  label: contactsStatus === 'loading' ? 'Loading contacts…' : 'No contacts yet',
906
852
  description: contactsStatus === 'loading' ? undefined : suiteIncluded ? 'Form submissions, member sign-ups, orders and bookings become contacts on their own; add one by hand or bring a list in from a CSV.' : 'Form submissions, member sign-ups, orders and bookings become contacts on their own.',
907
853
  action: contactsStatus === 'loading' ? undefined : !suiteIncluded ? /*#__PURE__*/ _jsxs(Stack, {
@@ -940,17 +886,12 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
940
886
  })
941
887
  }) : /*#__PURE__*/ _jsxs(_Fragment, {
942
888
  children: [
943
- windowClauses.length ? /*#__PURE__*/ _jsx(Typography, {
944
- variant: "caption",
945
- color: "text.secondary",
946
- children: (windowClauses.length === 1 ? `${(_filterHeaders_windowClauses__field = filterHeaders[windowClauses[0].field]) != null ? _filterHeaders_windowClauses__field : windowClauses[0].field} narrows ` : `${windowClauses.length} of the filters narrow `) + 'the loaded window — the newest 1,000 contacts' + (plan.served ? ` matching ${(_filterHeaders_plan_served_field = filterHeaders[plan.served.field]) != null ? _filterHeaders_plan_served_field : plan.served.field}, which reached every contact.` : '.') + ' Sorting reorders that window.'
947
- }) : null,
948
889
  /*#__PURE__*/ _jsx(ContactsBulkBar, {
949
890
  hostId: hostId,
950
891
  org: org,
951
892
  scope: dataScope,
952
893
  consentGroup: consentGroup,
953
- rows: visible,
894
+ rows: contacts,
954
895
  selected: selectedIds,
955
896
  onSelectedChange: setSelectedIds,
956
897
  csv: csvOptions,
@@ -959,7 +900,7 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
959
900
  /*#__PURE__*/ _jsx(CrmColumnOrderProvider, {
960
901
  value: grid.columnOrder,
961
902
  children: /*#__PURE__*/ _jsx(ListTable, {
962
- rows: visible,
903
+ rows: contacts,
963
904
  columns: grid.columns,
964
905
  slots: CRM_LIST_SLOTS,
965
906
  selectable: {
@@ -969,12 +910,12 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
969
910
  onOpen: (id)=>router.push(routes.contact(id)),
970
911
  /*
971
912
  * The grid's Filters panel and quick search edit the view's
972
- * clauses; the list answers them, onto the query where it
973
- * can and over the loaded window where it cannot, so the
974
- * grid must not filter again itself (AGL-3313). Opening the
975
- * panel is what reads the roster and the companies its
976
- * pickers name. Columns and sort are the view's, controlled
977
- * so a saved arrangement is what the grid shows.
913
+ * clauses; the list's query answers them (AGL-3321), so the
914
+ * grid must not filter again itself. Opening the panel is
915
+ * what reads the roster and the companies its pickers name.
916
+ * Columns are the view's, controlled so a saved arrangement
917
+ * is what the grid shows; the query orders the rows —
918
+ * newest change first — so the grid sorts nothing itself.
978
919
  */ filterMode: "server",
979
920
  filterModel: gridFilter.filterModel,
980
921
  onFilterModelChange: gridFilter.onFilterModelChange,
@@ -985,9 +926,20 @@ import { CrmSuiteLockedButton, CrmSuiteNotice, crmSuiteIncluded } from "./crm-su
985
926
  noRowsLabel: contactsStatus === 'loading' ? 'Loading…' : 'No contacts match these filters',
986
927
  columnVisibilityModel: grid.columnVisibilityModel,
987
928
  onColumnVisibilityModelChange: grid.onColumnVisibilityModelChange,
988
- sortModel: grid.sortModel,
989
- onSortModelChange: grid.onSortModelChange
929
+ sortingMode: "server",
930
+ disableColumnSorting: true,
931
+ loading: contactsStatus === 'loading',
932
+ // Paged by the footer below, so the grid must not also slice.
933
+ hideFooter: true
990
934
  })
935
+ }),
936
+ /*#__PURE__*/ _jsx(ListPagination, {
937
+ page: paged.page,
938
+ pageSize: paged.pageSize,
939
+ rowCount: contacts.length,
940
+ hasMore: paged.hasMore,
941
+ onPageChange: paged.setPage,
942
+ onPageSizeChange: paged.setPageSize
991
943
  })
992
944
  ]
993
945
  }),