@aglyn/plugins-crm 1.0.0-beta.198 → 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
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/constants/contact-filters.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 {\n type ContactFieldDefinition,\n CRM_CONTACT_VIEW_FIELDS,\n crmContactCustomColumn,\n} from '@aglyn/aglyn'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\n\n/*\n * Contacts (`orgs/{orgId}/contacts`, read scoped to a host).\n *\n * The card's listener is `limit(1000)` and stays that way — nobody needs\n * forty thousand rows streamed into a table. What that cap must NOT do is\n * decide what a search can find, and it did: an org with 40,000 contacts\n * searched 1,000 of them and answered \"no contacts match\" for the rest. The\n * head-count already learned this lesson (AGL-1706, a server aggregate); the\n * search is the same mistake one field over.\n *\n * ⛔ `marketingConsent`, `ordersCount` and `ltvCents` are written only when\n * they apply, so a `false`/`0` filter would return nothing rather than\n * everyone else. `isNotEmpty` is exact on them and is what the menu offers.\n */\nexport const CONTACT_LIST_FILTER_FIELDS: readonly ListFilterField[] = [\n {\n column: 'name',\n kind: 'text',\n path: 'name',\n lowerPath: 'nameLower',\n tokensPath: 'nameTokens',\n reversedPath: 'nameReversed',\n },\n {\n // `normalizeContactEmail` lower-cases before every write, so the stored\n // value IS its own normalized key.\n column: 'email',\n kind: 'text',\n path: 'email',\n lowerPath: 'email',\n presence: 'always',\n },\n {\n /*\n * Already an array of lower-cased tags, so `contains` is a plain\n * `array-contains` — the one place the word-level caveat does not apply,\n * because a tag is matched whole rather than by word prefix.\n */\n column: 'tags',\n kind: 'text',\n path: 'tags',\n tokensPath: 'tags',\n // Ordered by the timestamp the list already sorts on, not by the tag\n // array — `orderBy` on the same field an `array-contains` matches is a\n // composite index nobody wants to explain.\n containsOrderBy: 'updatedAt',\n // Newest first, as the unfiltered list, which is the composite declared\n // for it (AGL-3321).\n containsOrderDirection: 'desc',\n // `isAnyOf` is a saved segment's \"any of these tags\" (AGL-2617). The\n // query cannot serve it — `array-contains-any` is the scope clause's\n // — so the translator refuses it and the list matches it over the\n // window, which the caption says.\n operators: ['contains', 'isAnyOf', 'isNotEmpty'],\n },\n {\n /*\n * The forms a person came in through — the top-level `formIds` mirror\n * of the interactions' `formId` (AGL-2612), matched whole and as typed\n * because a form id is minted with mixed case. The form's own page links\n * here with its id; nobody types one. Ordered by the list's own\n * timestamp for the reason `tags` is, served by the\n * `(formIds CONTAINS, updatedAt DESC)` index.\n *\n * ⚠️ An `array-contains` on the mirror cannot share a query with the\n * `visibleTo` scope clause, so the list drops that clause for this one\n * filter and the rules admit the read to an org-wide member only — the\n * caveat the company contacts card states, stated again on the list.\n */\n column: 'formIds',\n kind: 'text',\n path: 'formIds',\n tokensPath: 'formIds',\n verbatimTokens: true,\n containsOrderBy: 'updatedAt',\n // Newest first, as the unfiltered list, which is the composite declared\n // for it (AGL-3321).\n containsOrderDirection: 'desc',\n operators: ['contains'],\n },\n { column: 'hostId', kind: 'exact', path: 'hostId', presence: 'always' },\n { column: 'ordersCount', kind: 'number', path: 'ordersCount' },\n { column: 'ltvCents', kind: 'number', path: 'ltvCents' },\n { column: 'createdAt', kind: 'date', path: 'createdAt', presence: 'always' },\n { column: 'updatedAt', kind: 'date', path: 'updatedAt', presence: 'always' },\n /*\n * When the earliest open task is due (AGL-2661) — window-only, because\n * the value is on the SHARED row but a record written before the field\n * existed has none, and Firestore cannot query for absence. `nullable`\n * is honest: the writer stores `null` for nothing scheduled, and the\n * matcher reads absent the same way, so \"is empty\" is the \"No next\n * activity\" filter over the loaded window.\n */\n {\n column: 'nextTaskAtMs',\n kind: 'date',\n path: 'nextTaskAtMs',\n windowOnly: true,\n presence: 'nullable',\n },\n /*\n * THE FACET FIELDS (AGL-2617) — window-only, every one of them.\n *\n * An owner, a stage, a company and the capture sources live on the\n * viewing group's facet of the shared contact row, and a facet path is\n * per group: a `where` on it would need an index per group, and the\n * translator would send a scoped member a query the rules deny. So these\n * are declared for the menu and for a saved view, matched over the loaded\n * window against the flattened row (`ContactRecord` carries each at the\n * top), and never put on a query — `windowOnly` is what holds that line.\n * The paths are the ROW'S, which is what the matcher reads.\n *\n * The values are picked, not typed — a uid from the roster, a stage from\n * the fixed list, a source from its labels, a company from the picker —\n * so `equals` and `isAnyOf` are what they offer; the section supplies\n * the choices. The column names are the contract `CRM_CONTACT_VIEW_FIELDS`\n * states, because the dynamic-list translator reads a view by them.\n */\n {\n column: CRM_CONTACT_VIEW_FIELDS.owner,\n kind: 'exact',\n path: 'ownerUid',\n windowOnly: true,\n operators: ['equals', 'isAnyOf', 'isNotEmpty'],\n },\n {\n column: CRM_CONTACT_VIEW_FIELDS.stage,\n kind: 'exact',\n path: 'lifecycleStage',\n windowOnly: true,\n operators: ['equals', 'isAnyOf', 'isNotEmpty'],\n },\n {\n // The `sources` presence map, matched on its keys — see `keysOf`.\n column: CRM_CONTACT_VIEW_FIELDS.source,\n kind: 'exact',\n path: 'sources',\n keysOf: true,\n windowOnly: true,\n operators: ['equals', 'isAnyOf'],\n },\n {\n column: CRM_CONTACT_VIEW_FIELDS.company,\n kind: 'exact',\n path: 'companyId',\n windowOnly: true,\n operators: ['equals', 'isNotEmpty'],\n },\n /*\n * The verdict on the address (AGL-3245) — window-only, on the shared row,\n * and `nullable` because most records carry none: \"is empty\" is the\n * filter for people nothing has been said about.\n */\n {\n column: 'emailState',\n kind: 'exact',\n path: 'emailState.status',\n windowOnly: true,\n presence: 'nullable',\n operators: ['equals', 'isAnyOf', 'isEmpty', 'isNotEmpty'],\n },\n]\n\n/**\n * One filter field per active custom contact field (AGL-2617).\n *\n * A custom value lives under the facet's `custom` map, so every one is\n * window-only for the reason the facet fields above are, and its column is\n * the one the table shows it under — `crmContactCustomColumn(key)` — so a\n * clause and a column agree on the name and a saved view's columns and\n * filters name the same thing. The kind follows the definition's type, and\n * `nullable` is how a cleared value is stored, which is what makes both\n * empty operators honest here.\n */\nexport function contactCustomFilterFields(\n definitions: readonly Pick<ContactFieldDefinition, 'key' | 'type' | 'retiredAt'>[],\n): ListFilterField[] {\n return definitions\n .filter((definition) => !definition.retiredAt)\n .map((definition) => {\n const column = crmContactCustomColumn(definition.key)\n const path = `custom.${definition.key}`\n switch (definition.type) {\n case 'number':\n return { column, kind: 'number', path, windowOnly: true, presence: 'nullable' }\n case 'date':\n return { column, kind: 'date', path, windowOnly: true, presence: 'nullable' }\n case 'checkbox':\n return { column, kind: 'boolean', path, windowOnly: true }\n case 'select':\n return {\n column,\n kind: 'exact',\n path,\n windowOnly: true,\n presence: 'nullable',\n operators: ['equals', 'isAnyOf', 'isEmpty', 'isNotEmpty'],\n }\n default:\n return {\n column,\n kind: 'text',\n path,\n windowOnly: true,\n presence: 'nullable',\n operators: ['contains', 'equals', 'startsWith', 'isEmpty', 'isNotEmpty'],\n }\n }\n })\n}\n\n/** The custom fields' headers, keyed by the column each filters as. */\nexport function contactCustomFilterHeaders(\n definitions: readonly Pick<ContactFieldDefinition, 'key' | 'label'>[],\n): Record<string, string> {\n return Object.fromEntries(\n definitions.map((definition) => [\n crmContactCustomColumn(definition.key),\n definition.label || definition.key,\n ]),\n )\n}\n\n/**\n * How every filterable contact field reads — on a chip, in the add-filter\n * picker, and as the header of a filter-only hidden column.\n */\nexport const CONTACT_LIST_FILTER_HEADERS: Readonly<Record<string, string>> = {\n name: 'Contact',\n email: 'Email',\n tags: 'Tags',\n formIds: 'Form ID',\n hostId: 'Site ID',\n ordersCount: 'Orders',\n ltvCents: 'Lifetime value (cents)',\n createdAt: 'Created',\n updatedAt: 'Updated',\n nextTaskAtMs: 'Next activity',\n [CRM_CONTACT_VIEW_FIELDS.owner]: 'Owner',\n [CRM_CONTACT_VIEW_FIELDS.stage]: 'Stage',\n [CRM_CONTACT_VIEW_FIELDS.source]: 'Source',\n [CRM_CONTACT_VIEW_FIELDS.company]: 'Company',\n emailState: 'Email',\n}\n"],"names":["CRM_CONTACT_VIEW_FIELDS","crmContactCustomColumn","CONTACT_LIST_FILTER_FIELDS","column","kind","path","lowerPath","tokensPath","reversedPath","presence","containsOrderBy","containsOrderDirection","operators","verbatimTokens","windowOnly","owner","stage","source","keysOf","company","contactCustomFilterFields","definitions","filter","definition","retiredAt","map","key","type","contactCustomFilterHeaders","Object","fromEntries","label","CONTACT_LIST_FILTER_HEADERS","name","email","tags","formIds","hostId","ordersCount","ltvCents","createdAt","updatedAt","nextTaskAtMs","emailState"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/constants/contact-filters.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 {\n type ContactFieldDefinition,\n CRM_CONTACT_VIEW_FIELDS,\n CRM_FACET_KEY_ANY_GROUP,\n type CrmFacetKeyField,\n crmContactCustomColumn,\n crmFacetKey,\n crmFacetKeyValue,\n EMAIL_STATE_STATUSES,\n} from '@aglyn/aglyn'\nimport type {\n ListFilterField,\n ListFilterRequest,\n} from '@aglyn/shared-ui-jsx/const/list-filter'\nimport type {\n ListQueryDeclaration,\n ListQuerySort,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport {\n CRM_LIST_SEARCH,\n type CrmClauseAsked,\n crmAnyOf,\n crmClauseValues,\n} from '../model/crm-list-query'\n\n/*==========================================\n * CONTACTS (`orgs/{orgId}/contacts`, read scoped to a host) — WHAT THE\n * PANEL OFFERS, AND WHAT THE QUERY ASKS (AGL-3321).\n *\n * Every clause and the search word are on the list's one Firestore query,\n * newest change first, paged by it. A contact is one row shared by every\n * holder, and most of what a holder filters by — the owner, the stage, the\n * sources, the company, the tags, a custom value, whether the person has\n * bought — lives on that holder's FACET, a path per holder no index can\n * reach. So every writer stamps `facetKeys` (`crmContactFacetKeys` in\n * `@aglyn/aglyn`): `{group}:{field}={value}` for each holder's value,\n * `{group}:{field}` for \"has one\", and the same under `*` for any holder.\n * Under a site a facet clause asks the viewing holder's key; at the\n * organization level, any holder's.\n *\n * What one query holds decides the rest:\n *\n * - ONE array clause. The scope clause (`visibleTo array-contains-any`),\n * the search (`searchTokens`), a name word (`nameTokens`), a form\n * (`formIds`) and a facet clause (`facetKeys`) all are one. The search\n * folds into the scope clause for a reader who may drop it; a facet or\n * form clause stands in the scope clause's place for that reader, since\n * only a record the viewing holder keeps carries its key. Two of them\n * at once is refused by name.\n * - ONE range. `updatedAt`, the field the list is ordered by, composes\n * with anything; Created and a name or address prefix order the list by\n * their own field and so stand alone beside the scope clause\n * (`contactSoloClause`), served by the `(visibleTo, field)` composites.\n * - equalities (the address, the site, the verdict, \"No next activity\")\n * compose freely, one `(field, updatedAt DESC)` composite each.\n *\n * The per-holder FIGURES (orders, lifetime value) live on each holder's\n * facet, where no range reaches — a top-level `ordersCount` was never\n * written — so they offer \"is not empty\" alone: \"has bought\".\n *\n * A collaborator's search is the start of the name (`CONTACT_PREFIX_SEARCH`).\n *=========================================*/\n\n/**\n * A word of the canonical name (`nameTokens`), the whole of it, or its start\n * or end — `nameLower`, which every contact carries, and `nameReversed`.\n */\nconst NAME_FIELD: ListFilterField = {\n column: 'name',\n kind: 'text',\n path: 'name',\n lowerPath: 'nameLower',\n tokensPath: 'nameTokens',\n reversedPath: 'nameReversed',\n operators: ['contains', 'equals', 'startsWith', 'endsWith'],\n}\n\n/**\n * The address. `normalizeContactEmail` lower-cases before every write, so the\n * stored value IS its own normalized key.\n */\nconst EMAIL_FIELD: ListFilterField = {\n column: 'email',\n kind: 'text',\n path: 'email',\n lowerPath: 'email',\n presence: 'always',\n operators: ['equals', 'startsWith'],\n}\n\n/** When the person was first captured — a range the list is then ordered by. */\nconst CREATED_FIELD: ListFilterField = {\n column: CRM_CONTACT_VIEW_FIELDS.createdAt,\n kind: 'date',\n path: 'createdAt',\n presence: 'always',\n operators: ['is', 'after', 'onOrAfter', 'before', 'onOrBefore'],\n}\n\n/**\n * The forms a person came in through — the top-level `formIds` mirror of the\n * interactions' `formId` (AGL-2612), matched whole and as typed because a\n * form id is minted with mixed case. The form's own page links here with its\n * id; nobody types one.\n */\nconst FORM_FIELD: ListFilterField = {\n column: 'formIds',\n kind: 'text',\n path: 'formIds',\n tokensPath: 'formIds',\n verbatimTokens: true,\n operators: ['contains'],\n}\n\n/** What the Filters panel offers on a contact, in the grid's grammar. */\nexport const CONTACT_LIST_FILTER_FIELDS: readonly ListFilterField[] = [\n NAME_FIELD,\n EMAIL_FIELD,\n {\n // A holder's tags, matched whole and case-insensitively.\n column: CRM_CONTACT_VIEW_FIELDS.tags,\n kind: 'exact',\n path: 'tags',\n operators: ['contains', 'isAnyOf', 'isNotEmpty'],\n },\n FORM_FIELD,\n { column: 'hostId', kind: 'exact', path: 'hostId', presence: 'always' },\n // Per-holder figures: \"has bought\", never a range across holders.\n {\n column: CRM_CONTACT_VIEW_FIELDS.orders,\n kind: 'number',\n path: 'ordersCount',\n operators: ['isNotEmpty'],\n },\n {\n column: CRM_CONTACT_VIEW_FIELDS.ltv,\n kind: 'number',\n path: 'ltvCents',\n operators: ['isNotEmpty'],\n },\n CREATED_FIELD,\n {\n column: CRM_CONTACT_VIEW_FIELDS.updatedAt,\n kind: 'date',\n path: 'updatedAt',\n presence: 'always',\n operators: ['is', 'after', 'onOrAfter', 'before', 'onOrBefore'],\n },\n /*\n * \"No next activity\" (AGL-2661): `nextTaskAtMs == null`, which every\n * contact carries from its creation until a task is scheduled against it.\n */\n {\n column: 'nextTaskAtMs',\n kind: 'date',\n path: 'nextTaskAtMs',\n presence: 'nullable',\n storedAs: 'millis',\n operators: ['isEmpty'],\n },\n /*\n * THE FACET FIELDS (AGL-2617). The values are picked, not typed — a uid\n * from the roster, a stage from the fixed list, a source from its labels,\n * a company from the picker — so `equals` and `isAnyOf` are what they\n * offer; the section supplies the choices. The column names are the\n * contract `CRM_CONTACT_VIEW_FIELDS` states, because the dynamic-list\n * translator reads a view by them. The paths are the flattened ROW'S.\n */\n {\n column: CRM_CONTACT_VIEW_FIELDS.owner,\n kind: 'exact',\n path: 'ownerUid',\n operators: ['equals', 'isAnyOf', 'isNotEmpty'],\n },\n {\n column: CRM_CONTACT_VIEW_FIELDS.stage,\n kind: 'exact',\n path: 'lifecycleStage',\n operators: ['equals', 'isAnyOf', 'isNotEmpty'],\n },\n {\n // The `sources` presence map, matched on its keys — see `keysOf`.\n column: CRM_CONTACT_VIEW_FIELDS.source,\n kind: 'exact',\n path: 'sources',\n keysOf: true,\n operators: ['equals', 'isAnyOf'],\n },\n {\n column: CRM_CONTACT_VIEW_FIELDS.company,\n kind: 'exact',\n path: 'companyId',\n operators: ['equals', 'isNotEmpty'],\n },\n /*\n * The verdict on the address (AGL-3245), asked through `emailStatus` —\n * `none` for a person nothing has been said about, which is what \"is\n * empty\" asks.\n */\n {\n column: 'emailState',\n kind: 'exact',\n path: 'emailState.status',\n presence: 'nullable',\n operators: ['equals', 'isAnyOf', 'isEmpty', 'isNotEmpty'],\n },\n]\n\n/**\n * One filter field per active custom contact field (AGL-2617). A custom\n * value lives under the holder's `custom` map, so it is asked through the\n * holder's facet key: equality on a picked, typed or ticked value, and\n * \"is not empty\". Its column is the one the table shows it under —\n * `crmContactCustomColumn(key)` — so a clause and a column agree on the name.\n */\nexport function contactCustomFilterFields(\n definitions: readonly Pick<\n ContactFieldDefinition,\n 'key' | 'type' | 'retiredAt'\n >[],\n): ListFilterField[] {\n return definitions\n .filter((definition) => !definition.retiredAt)\n .map((definition): ListFilterField => {\n const column = crmContactCustomColumn(definition.key)\n const path = `custom.${definition.key}`\n switch (definition.type) {\n case 'number':\n return {\n column,\n kind: 'number',\n path,\n operators: ['=', 'isNotEmpty'],\n }\n case 'date':\n return { column, kind: 'date', path, operators: ['isNotEmpty'] }\n case 'checkbox':\n return { column, kind: 'boolean', path }\n case 'select':\n return {\n column,\n kind: 'exact',\n path,\n operators: ['equals', 'isAnyOf', 'isNotEmpty'],\n }\n default:\n return {\n column,\n kind: 'text',\n path,\n lowerPath: path,\n operators: ['equals', 'isNotEmpty'],\n }\n }\n })\n}\n\n/** The custom fields' headers, keyed by the column each filters as. */\nexport function contactCustomFilterHeaders(\n definitions: readonly Pick<ContactFieldDefinition, 'key' | 'label'>[],\n): Record<string, string> {\n return Object.fromEntries(\n definitions.map((definition) => [\n crmContactCustomColumn(definition.key),\n definition.label || definition.key,\n ]),\n )\n}\n\n/**\n * How every filterable contact field reads — on a chip, in the add-filter\n * picker, and as the header of a filter-only hidden column.\n */\nexport const CONTACT_LIST_FILTER_HEADERS: Readonly<Record<string, string>> = {\n name: 'Contact',\n email: 'Email',\n tags: 'Tags',\n formIds: 'Form ID',\n hostId: 'Site ID',\n ordersCount: 'Orders',\n ltvCents: 'Lifetime value (cents)',\n createdAt: 'Created',\n updatedAt: 'Updated',\n nextTaskAtMs: 'Next activity',\n [CRM_CONTACT_VIEW_FIELDS.owner]: 'Owner',\n [CRM_CONTACT_VIEW_FIELDS.stage]: 'Stage',\n [CRM_CONTACT_VIEW_FIELDS.source]: 'Source',\n [CRM_CONTACT_VIEW_FIELDS.company]: 'Company',\n emailState: 'Email',\n}\n\n/*------------------------------------------\n * THE QUERY THE CONTACTS LIST RUNS.\n *-----------------------------------------*/\n\n/** The list's one order: newest change first. */\nexport const CONTACT_LIST_SORTS: readonly ListQuerySort[] = [\n { path: 'updatedAt', direction: 'desc' },\n]\n\n/** The stored array a facet clause is asked of. */\nexport const CONTACT_FACET_KEYS_PATH = 'facetKeys'\n\n/** The fields the Contacts query asks — each a stored field, as named. */\nexport const CONTACT_QUERY_FIELDS: readonly ListFilterField[] = [\n NAME_FIELD,\n EMAIL_FIELD,\n FORM_FIELD,\n CREATED_FIELD,\n { column: 'hostId', kind: 'exact', path: 'hostId', presence: 'always' },\n {\n column: 'updatedAt',\n kind: 'date',\n path: 'updatedAt',\n presence: 'always',\n operators: ['is', 'after', 'onOrAfter', 'before', 'onOrBefore'],\n },\n {\n column: 'nextTaskAtMs',\n kind: 'date',\n path: 'nextTaskAtMs',\n presence: 'nullable',\n storedAs: 'millis',\n operators: ['isEmpty'],\n },\n {\n column: 'emailStatus',\n kind: 'exact',\n path: 'emailStatus',\n operators: ['equals', 'isAnyOf'],\n },\n {\n column: CONTACT_FACET_KEYS_PATH,\n kind: 'exact',\n path: CONTACT_FACET_KEYS_PATH,\n tokensPath: CONTACT_FACET_KEYS_PATH,\n operators: ['contains', 'isAnyOf'],\n },\n]\n\nexport const CONTACT_LIST_DECLARATION: ListQueryDeclaration = {\n fields: CONTACT_QUERY_FIELDS,\n sorts: CONTACT_LIST_SORTS,\n search: CRM_LIST_SEARCH,\n}\n\n/** The grid fields a stored clause passes to the query unchanged. */\nconst AS_STORED = new Set([\n 'name',\n 'email',\n 'formIds',\n 'hostId',\n 'createdAt',\n 'updatedAt',\n 'nextTaskAtMs',\n])\n\n/**\n * The ranges that stand ALONE beside the scope clause: Created and a name\n * or address prefix each order the list by their own field, which the\n * `(visibleTo, field)` composites serve and nothing beside them. Updated is\n * not one: the list is ordered by it already, so it composes freely.\n */\nexport function contactSoloClause(clause: ListFilterRequest): boolean {\n if (clause.field === 'createdAt') return true\n if (clause.field === 'name') return clause.op === 'startsWith' || clause.op === 'endsWith'\n return clause.field === 'email' && clause.op === 'startsWith'\n}\n\n/**\n * A collaborator's search (see `prefixSearch` in `useCrmListQuery`): the\n * start of the contact's name, beside the scope clause.\n */\nexport const CONTACT_PREFIX_SEARCH = {\n field: NAME_FIELD,\n notice: 'Search matches the start of a contact’s name for access limited to specific sites.',\n}\n\n/** The facet field each grid column is keyed under. */\nconst FACET_FIELDS: Readonly<Record<string, CrmFacetKeyField>> = {\n [CRM_CONTACT_VIEW_FIELDS.owner]: 'owner',\n [CRM_CONTACT_VIEW_FIELDS.stage]: 'stage',\n [CRM_CONTACT_VIEW_FIELDS.source]: 'source',\n [CRM_CONTACT_VIEW_FIELDS.company]: 'company',\n [CRM_CONTACT_VIEW_FIELDS.tags]: 'tag',\n [CRM_CONTACT_VIEW_FIELDS.orders]: 'orders',\n [CRM_CONTACT_VIEW_FIELDS.ltv]: 'ltv',\n}\n\n/** Who is asking: the viewing holder, and whether their query may drop the scope clause. */\nexport interface ContactQueryReader {\n /** The viewing holder's group id under a site; `null` at the organization level. */\n groupId: string | null\n /**\n * Whether the reader's query may stand a facet or form clause in the\n * scope clause's place (`useCrmFoldsScope`) — always at the organization\n * level, where there is no scope clause.\n */\n foldsScope: boolean\n}\n\n/** Why a reader whose access is some sites cannot filter by a holder's field. */\nconst LIMITED_ACCESS =\n 'your access is limited to specific sites, which this filter cannot be combined with — an organization administrator can use it'\n\n/** The facet field a grid column names, or null for one that is not a facet field. */\nfunction facetFieldOf(column: string): CrmFacetKeyField | null {\n if (FACET_FIELDS[column]) return FACET_FIELDS[column]\n if (column.startsWith(CRM_CONTACT_VIEW_FIELDS.custom)) {\n return `custom.${column.slice(CRM_CONTACT_VIEW_FIELDS.custom.length)}`\n }\n return null\n}\n\n/**\n * One grid clause as the clause the Contacts query asks — see the block at\n * the top. A clause a query cannot serve for this reader is refused by\n * name, never matched over the rows a page loaded.\n */\nexport function contactQueryClause(\n clause: ListFilterRequest,\n reader: ContactQueryReader,\n): CrmClauseAsked {\n if (AS_STORED.has(clause.field)) {\n if (clause.field === 'formIds' && !reader.foldsScope)\n return { refused: LIMITED_ACCESS }\n return { field: clause.field, op: clause.op, value: clause.value }\n }\n if (clause.field === 'emailState') {\n if (clause.op === 'isEmpty')\n return { field: 'emailStatus', op: 'equals', value: 'none' }\n if (clause.op === 'isNotEmpty')\n return crmAnyOf('emailStatus', EMAIL_STATE_STATUSES)\n const values = crmClauseValues(clause)\n if (\n values.some(\n (value) => !(EMAIL_STATE_STATUSES as readonly string[]).includes(value),\n )\n ) {\n return { refused: 'pick one of the choices' }\n }\n return crmAnyOf('emailStatus', values)\n }\n const facet = facetFieldOf(clause.field)\n if (!facet) return { refused: 'this list does not filter by that' }\n if (!reader.foldsScope) return { refused: LIMITED_ACCESS }\n const group = reader.groupId ?? CRM_FACET_KEY_ANY_GROUP\n if (clause.op === 'isNotEmpty') {\n return {\n field: CONTACT_FACET_KEYS_PATH,\n op: 'contains',\n value: crmFacetKey(group, facet),\n }\n }\n const keys: string[] = []\n for (const value of crmClauseValues(clause)) {\n const key = crmFacetKeyValue(clause.op === '=' ? Number(value) : value)\n if (key === null) return { refused: 'no value yet' }\n keys.push(crmFacetKey(group, facet, key))\n }\n const asked = crmAnyOf(CONTACT_FACET_KEYS_PATH, keys)\n // One key is an `array-contains`; several, an `array-contains-any`.\n return 'refused' in asked || asked.op === 'isAnyOf'\n ? asked\n : { ...asked, op: 'contains' }\n}\n\n/**\n * Whether an asked clause stands in for the scope clause: a facet key of\n * the viewing holder, which only a record that holder keeps carries, or a\n * form, which only its site's captures carry.\n */\nexport function contactClauseImpliesScope(groupId: string | null) {\n return (clause: ListFilterRequest): boolean => {\n if (clause.field === 'formIds') return true\n if (clause.field !== CONTACT_FACET_KEYS_PATH || !groupId) return false\n return crmClauseValues(clause).every((key) => key.startsWith(`${groupId}:`))\n }\n}\n\n/**\n * The query shapes the Contacts list can send, as declarations the index\n * pin enumerates (AGL-3321): the list's own, without its solo ranges, whose\n * every equality rides `(field, updatedAt DESC)`; and each solo range beside\n * the scope clause alone.\n */\nexport function contactIndexShapes(): ListQueryDeclaration[] {\n const soloOps = new Set(['startsWith', 'endsWith'])\n const main: ListQueryDeclaration = {\n ...CONTACT_LIST_DECLARATION,\n fields: CONTACT_QUERY_FIELDS.filter((field) => field !== CREATED_FIELD).map((field) =>\n field.operators\n ? { ...field, operators: field.operators.filter((op) => !soloOps.has(op)) }\n : field,\n ),\n }\n const solo: ListQueryDeclaration = {\n fields: [\n { ...NAME_FIELD, operators: ['startsWith', 'endsWith'] },\n { ...EMAIL_FIELD, operators: ['startsWith'] },\n CREATED_FIELD,\n ],\n sorts: [],\n }\n return [main, solo]\n}\n"],"names":["CRM_CONTACT_VIEW_FIELDS","CRM_FACET_KEY_ANY_GROUP","crmContactCustomColumn","crmFacetKey","crmFacetKeyValue","EMAIL_STATE_STATUSES","CRM_LIST_SEARCH","crmAnyOf","crmClauseValues","NAME_FIELD","column","kind","path","lowerPath","tokensPath","reversedPath","operators","EMAIL_FIELD","presence","CREATED_FIELD","createdAt","FORM_FIELD","verbatimTokens","CONTACT_LIST_FILTER_FIELDS","tags","orders","ltv","updatedAt","storedAs","owner","stage","source","keysOf","company","contactCustomFilterFields","definitions","filter","definition","retiredAt","map","key","type","contactCustomFilterHeaders","Object","fromEntries","label","CONTACT_LIST_FILTER_HEADERS","name","email","formIds","hostId","ordersCount","ltvCents","nextTaskAtMs","emailState","CONTACT_LIST_SORTS","direction","CONTACT_FACET_KEYS_PATH","CONTACT_QUERY_FIELDS","CONTACT_LIST_DECLARATION","fields","sorts","search","AS_STORED","Set","contactSoloClause","clause","field","op","CONTACT_PREFIX_SEARCH","notice","FACET_FIELDS","LIMITED_ACCESS","facetFieldOf","startsWith","custom","slice","length","contactQueryClause","reader","has","foldsScope","refused","value","values","some","includes","facet","group","groupId","keys","Number","push","asked","contactClauseImpliesScope","every","contactIndexShapes","soloOps","main","solo"],"mappings":"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"}
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter';
18
+ import type { ListQueryDeclaration, ListQueryFilter, ListQuerySort } from '@aglyn/shared-ui-jsx/const/list-query-plan';
19
+ /** The table's one order: newest change first. */
20
+ export declare const DEAL_LIST_SORTS: readonly ListQuerySort[];
21
+ /** What the deals table's Filters panel offers — every one asked of the query. */
22
+ export declare const DEAL_LIST_FILTER_FIELDS: readonly ListFilterField[];
23
+ export declare const DEAL_LIST_DECLARATION: ListQueryDeclaration;
24
+ /**
25
+ * A collaborator's search (see `prefixSearch` in `useCrmListQuery`): the
26
+ * start of the title, `titleLower`, beside the scope clause.
27
+ */
28
+ export declare const DEAL_PREFIX_SEARCH: {
29
+ field: {
30
+ column: string;
31
+ kind: "text";
32
+ path: string;
33
+ lowerPath: string;
34
+ presence: "always";
35
+ operators: string[];
36
+ };
37
+ notice: string;
38
+ };
39
+ /** The chosen pipeline, as the query's base beside the scope clause. */
40
+ export declare function dealPipelineBase(pipelineId: string | null): ListQueryFilter[];
41
+ /** The base's shape for `listQueryIndexes`: the pipeline, an equality. */
42
+ export declare const DEAL_LIST_PIPELINE_BASE_INDEX: readonly [{
43
+ readonly path: "pipelineId";
44
+ }];
@@ -0,0 +1,78 @@
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
+ */ import { CRM_NEXT_ACTIVITY_FILTER_FIELD } from "../components/crm-next-activity-column.js";
17
+ import { CRM_LIST_SEARCH, crmSelectField } from "../model/crm-list-query.js";
18
+ /*
19
+ * THE DEALS TABLE'S QUERY (AGL-3321).
20
+ *
21
+ * `orgs/{orgId}/deals` in the chosen pipeline — the pipeline is the query's
22
+ * base beside the scope clause — newest change first, paged by that query.
23
+ * The status, "No next activity" (`nextTaskAtMs == null`, which every deal
24
+ * is created carrying) and the search word are predicates on the same
25
+ * query. The search reads `searchTokens`, the word prefixes of the title
26
+ * every deal writer stamps (`crmDealListFields`), so "renewal" finds "Acme
27
+ * renewal" on any page. Under a site the scope clause is the query's one
28
+ * array clause and the search folds into it for a reader who may drop it.
29
+ *
30
+ * The board is not this: it draws a pipeline's open deals whole, bounded,
31
+ * and filters nothing.
32
+ */ /** The table's one order: newest change first. */ export const DEAL_LIST_SORTS = [
33
+ {
34
+ path: 'updatedAt',
35
+ direction: 'desc'
36
+ }
37
+ ];
38
+ /** What the deals table's Filters panel offers — every one asked of the query. */ export const DEAL_LIST_FILTER_FIELDS = [
39
+ crmSelectField('status'),
40
+ CRM_NEXT_ACTIVITY_FILTER_FIELD
41
+ ];
42
+ export const DEAL_LIST_DECLARATION = {
43
+ fields: DEAL_LIST_FILTER_FIELDS,
44
+ sorts: DEAL_LIST_SORTS,
45
+ search: CRM_LIST_SEARCH
46
+ };
47
+ /**
48
+ * A collaborator's search (see `prefixSearch` in `useCrmListQuery`): the
49
+ * start of the title, `titleLower`, beside the scope clause.
50
+ */ export const DEAL_PREFIX_SEARCH = {
51
+ field: {
52
+ column: 'title',
53
+ kind: 'text',
54
+ path: 'title',
55
+ lowerPath: 'titleLower',
56
+ presence: 'always',
57
+ operators: [
58
+ 'startsWith'
59
+ ]
60
+ },
61
+ notice: 'Search matches the start of a deal’s title for access limited to specific sites.'
62
+ };
63
+ /** The chosen pipeline, as the query's base beside the scope clause. */ export function dealPipelineBase(pipelineId) {
64
+ return pipelineId ? [
65
+ {
66
+ path: 'pipelineId',
67
+ op: '==',
68
+ value: pipelineId
69
+ }
70
+ ] : [];
71
+ }
72
+ /** The base's shape for `listQueryIndexes`: the pipeline, an equality. */ export const DEAL_LIST_PIPELINE_BASE_INDEX = [
73
+ {
74
+ path: 'pipelineId'
75
+ }
76
+ ];
77
+
78
+ //# sourceMappingURL=deal-filters.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/constants/deal-filters.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 type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport type {\n ListQueryDeclaration,\n ListQueryFilter,\n ListQuerySort,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport { CRM_NEXT_ACTIVITY_FILTER_FIELD } from '../components/crm-next-activity-column'\nimport { CRM_LIST_SEARCH, crmSelectField } from '../model/crm-list-query'\n\n/*\n * THE DEALS TABLE'S QUERY (AGL-3321).\n *\n * `orgs/{orgId}/deals` in the chosen pipeline — the pipeline is the query's\n * base beside the scope clause — newest change first, paged by that query.\n * The status, \"No next activity\" (`nextTaskAtMs == null`, which every deal\n * is created carrying) and the search word are predicates on the same\n * query. The search reads `searchTokens`, the word prefixes of the title\n * every deal writer stamps (`crmDealListFields`), so \"renewal\" finds \"Acme\n * renewal\" on any page. Under a site the scope clause is the query's one\n * array clause and the search folds into it for a reader who may drop it.\n *\n * The board is not this: it draws a pipeline's open deals whole, bounded,\n * and filters nothing.\n */\n\n/** The table's one order: newest change first. */\nexport const DEAL_LIST_SORTS: readonly ListQuerySort[] = [\n { path: 'updatedAt', direction: 'desc' },\n]\n\n/** What the deals table's Filters panel offers — every one asked of the query. */\nexport const DEAL_LIST_FILTER_FIELDS: readonly ListFilterField[] = [\n crmSelectField('status'),\n CRM_NEXT_ACTIVITY_FILTER_FIELD,\n]\n\nexport const DEAL_LIST_DECLARATION: ListQueryDeclaration = {\n fields: DEAL_LIST_FILTER_FIELDS,\n sorts: DEAL_LIST_SORTS,\n search: CRM_LIST_SEARCH,\n}\n\n/**\n * A collaborator's search (see `prefixSearch` in `useCrmListQuery`): the\n * start of the title, `titleLower`, beside the scope clause.\n */\nexport const DEAL_PREFIX_SEARCH = {\n field: {\n column: 'title',\n kind: 'text',\n path: 'title',\n lowerPath: 'titleLower',\n presence: 'always',\n operators: ['startsWith'],\n } satisfies ListFilterField,\n notice: 'Search matches the start of a deal’s title for access limited to specific sites.',\n}\n\n/** The chosen pipeline, as the query's base beside the scope clause. */\nexport function dealPipelineBase(pipelineId: string | null): ListQueryFilter[] {\n return pipelineId ? [{ path: 'pipelineId', op: '==', value: pipelineId }] : []\n}\n\n/** The base's shape for `listQueryIndexes`: the pipeline, an equality. */\nexport const DEAL_LIST_PIPELINE_BASE_INDEX = [{ path: 'pipelineId' }] as const\n"],"names":["CRM_NEXT_ACTIVITY_FILTER_FIELD","CRM_LIST_SEARCH","crmSelectField","DEAL_LIST_SORTS","path","direction","DEAL_LIST_FILTER_FIELDS","DEAL_LIST_DECLARATION","fields","sorts","search","DEAL_PREFIX_SEARCH","field","column","kind","lowerPath","presence","operators","notice","dealPipelineBase","pipelineId","op","value","DEAL_LIST_PIPELINE_BASE_INDEX"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAQD,SAASA,8BAA8B,QAAQ,4CAAwC;AACvF,SAASC,eAAe,EAAEC,cAAc,QAAQ,6BAAyB;AAEzE;;;;;;;;;;;;;;CAcC,GAED,gDAAgD,GAChD,OAAO,MAAMC,kBAA4C;IACvD;QAAEC,MAAM;QAAaC,WAAW;IAAO;CACxC,CAAA;AAED,gFAAgF,GAChF,OAAO,MAAMC,0BAAsD;IACjEJ,eAAe;IACfF;CACD,CAAA;AAED,OAAO,MAAMO,wBAA8C;IACzDC,QAAQF;IACRG,OAAON;IACPO,QAAQT;AACV,EAAC;AAED;;;CAGC,GACD,OAAO,MAAMU,qBAAqB;IAChCC,OAAO;QACLC,QAAQ;QACRC,MAAM;QACNV,MAAM;QACNW,WAAW;QACXC,UAAU;QACVC,WAAW;YAAC;SAAa;IAC3B;IACAC,QAAQ;AACV,EAAC;AAED,sEAAsE,GACtE,OAAO,SAASC,iBAAiBC,UAAyB;IACxD,OAAOA,aAAa;QAAC;YAAEhB,MAAM;YAAciB,IAAI;YAAMC,OAAOF;QAAW;KAAE,GAAG,EAAE;AAChF;AAEA,wEAAwE,GACxE,OAAO,MAAMG,gCAAgC;IAAC;QAAEnB,MAAM;IAAa;CAAE,CAAS"}
@@ -0,0 +1,68 @@
1
+ import type { ListFilterField, ListFilterRequest } from '@aglyn/shared-ui-jsx/const/list-filter';
2
+ import { type ListQueryDeclaration, type ListQueryFilter, type ListQuerySort } from '@aglyn/shared-ui-jsx/const/list-query-plan';
3
+ import { type UseListQueryResult } from '@aglyn/tenant-feature-instance/hooks/use-list-query';
4
+ /**
5
+ * Whether this reader's CRM queries may drop the `visibleTo` clause — fold
6
+ * the search into it, or stand an array clause in its place (AGL-3321).
7
+ *
8
+ * At the organization level there is no clause to drop. Under a site the
9
+ * rules prove a query without one only for an ORG-WIDE member, so the
10
+ * member's reach is read, and until it has answered the answer is no: a
11
+ * search refused for a second is better than a list the rules refuse.
12
+ */
13
+ export declare function useCrmFoldsScope(orgId: string | null | undefined, visibleTo: readonly string[] | null): boolean;
14
+ export interface UseCrmListQueryOptions {
15
+ /** The org root, or `null` until it is known. */
16
+ scope: readonly [string, string] | null;
17
+ /** The collection under the org root. */
18
+ collection: string;
19
+ /** The reader's scope tokens, or `null` at the organization level. */
20
+ visibleTo: readonly string[] | null;
21
+ /** Whether the reader may run queries without the scope clause — see `useCrmFoldsScope`. */
22
+ foldsScope: boolean;
23
+ declaration: ListQueryDeclaration;
24
+ clauses: readonly ListFilterRequest[];
25
+ search?: readonly string[];
26
+ sort?: ListQuerySort | null;
27
+ /** Predicates beside the scope the list always applies (a task view, a pipeline). */
28
+ base?: readonly ListQueryFilter[];
29
+ /**
30
+ * A clause that IMPLIES the scope clause: one no record outside the
31
+ * reader's scope can answer (a contact's facet key for the viewing
32
+ * holder). When the reader may run a query without the scope clause
33
+ * (`foldsScope`) and such a clause reaches the query, it stands in the
34
+ * scope clause's place — the one array clause a query has is then spent
35
+ * on it. Only when it is actually SERVED: a scope dropped for a clause the
36
+ * plan then refused would list records outside the scope.
37
+ */
38
+ impliesScope?: (clause: ListFilterRequest) => boolean;
39
+ /**
40
+ * A reader whose access is some sites keeps the scope clause, which is the
41
+ * query's one array clause, so their search cannot be a token search —
42
+ * folded or not, the rules prove it only from `visibleTo`. It is served
43
+ * instead as a PREFIX of this text field beside the scope clause (a range
44
+ * over its `lowerPath`), and `notice` says so. Absent, their search is
45
+ * refused by name.
46
+ */
47
+ prefixSearch?: {
48
+ field: ListFilterField;
49
+ notice: string;
50
+ };
51
+ /**
52
+ * A clause that stands ALONE beside the scope: a range that orders the
53
+ * list by its own field, served without a composite per other clause. A
54
+ * solo clause set beside any other, or beside the search, is refused by
55
+ * name rather than asked.
56
+ */
57
+ soloClause?: (clause: ListFilterRequest) => boolean;
58
+ /** `false` holds the query — no read until the list has what it needs. */
59
+ enabled?: boolean;
60
+ pageSize?: number;
61
+ }
62
+ /**
63
+ * A CRM list on its query (AGL-3321): the scope clause as the plan's base,
64
+ * the reader's declaration (`crmListDeclarationFor`), every clause and the
65
+ * search word planned together and paged by `useListQuery`.
66
+ */
67
+ export declare function useCrmListQuery<T>(options: UseCrmListQueryOptions): UseListQueryResult<T>;
68
+ export default useCrmListQuery;
@@ -0,0 +1,182 @@
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 { nameSearchNormalizers } from "@aglyn/aglyn/app-utils/name-search";
19
+ import { planListQuery } from "@aglyn/shared-ui-jsx/const/list-query-plan";
20
+ import { useFirestore, useScopeTokens } from "@aglyn/tenant-feature-instance";
21
+ import { useListQuery } from "@aglyn/tenant-feature-instance/hooks/use-list-query";
22
+ import { collection as collectionRef } from "firebase/firestore";
23
+ import { useMemo } from "react";
24
+ import { crmListBase, crmListDeclarationFor } from "../model/crm-list-query.js";
25
+ import { crmScopeListable } from "./use-crm-scope.js";
26
+ /**
27
+ * Whether this reader's CRM queries may drop the `visibleTo` clause — fold
28
+ * the search into it, or stand an array clause in its place (AGL-3321).
29
+ *
30
+ * At the organization level there is no clause to drop. Under a site the
31
+ * rules prove a query without one only for an ORG-WIDE member, so the
32
+ * member's reach is read, and until it has answered the answer is no: a
33
+ * search refused for a second is better than a list the rules refuse.
34
+ */ export function useCrmFoldsScope(orgId, visibleTo) {
35
+ const reach = useScopeTokens(visibleTo && orgId ? orgId : undefined);
36
+ return visibleTo === null || reach.loaded && reach.orgWide;
37
+ }
38
+ /**
39
+ * A CRM list on its query (AGL-3321): the scope clause as the plan's base,
40
+ * the reader's declaration (`crmListDeclarationFor`), every clause and the
41
+ * search word planned together and paged by `useListQuery`.
42
+ */ export function useCrmListQuery(options) {
43
+ var _ref, _ref1;
44
+ const firestore = useFirestore();
45
+ const { scope, collection, visibleTo, foldsScope, declaration, clauses, search = [], sort = null, base = [], impliesScope, prefixSearch, soloClause, enabled = true, pageSize } = options;
46
+ const readable = enabled && Boolean(scope) && crmScopeListable(visibleTo);
47
+ const ref = useMemo(()=>readable && scope ? collectionRef(firestore, scope[0], scope[1], collection) : null, [
48
+ readable,
49
+ firestore,
50
+ scope,
51
+ collection
52
+ ]);
53
+ // A collaborator's search, as the prefix range their rules can prove.
54
+ const collaboratorPrefix = visibleTo && !foldsScope && prefixSearch && search.some((word)=>word.trim()) ? prefixSearch : null;
55
+ const reader = useMemo(()=>{
56
+ const own = crmListDeclarationFor(declaration, foldsScope);
57
+ return collaboratorPrefix ? _extends({}, own, {
58
+ fields: [
59
+ ...own.fields,
60
+ collaboratorPrefix.field
61
+ ]
62
+ }) : own;
63
+ }, [
64
+ declaration,
65
+ foldsScope,
66
+ collaboratorPrefix
67
+ ]);
68
+ const asked = useMemo(()=>{
69
+ var _ref;
70
+ var _search_find;
71
+ const prefixClause = collaboratorPrefix ? {
72
+ field: collaboratorPrefix.field.column,
73
+ op: 'startsWith',
74
+ value: (_ref = (_search_find = search.find((word)=>word.trim())) == null ? void 0 : _search_find.trim()) != null ? _ref : ''
75
+ } : null;
76
+ const all = prefixClause ? [
77
+ ...clauses,
78
+ prefixClause
79
+ ] : [
80
+ ...clauses
81
+ ];
82
+ const searching = !prefixClause && search.some((word)=>word.trim());
83
+ const alone = (clause)=>Boolean(soloClause == null ? void 0 : soloClause(clause)) && (all.length > 1 || searching);
84
+ return {
85
+ clauses: all.filter((clause)=>!alone(clause)),
86
+ search: prefixClause ? [] : search,
87
+ refused: all.filter(alone).map((clause)=>({
88
+ clause,
89
+ reason: 'it orders the list by its own field, so it stands alone — clear the other filters and the search to use it'
90
+ })),
91
+ prefixClause
92
+ };
93
+ }, [
94
+ clauses,
95
+ search,
96
+ collaboratorPrefix,
97
+ soloClause
98
+ ]);
99
+ const request = useMemo(()=>{
100
+ const { clauses: kept, search: words } = asked;
101
+ const scoped = {
102
+ clauses: kept,
103
+ search: words,
104
+ sort,
105
+ base: [
106
+ ...crmListBase(visibleTo),
107
+ ...base
108
+ ]
109
+ };
110
+ if (!visibleTo || !foldsScope || !impliesScope || !kept.some(impliesScope)) return scoped;
111
+ // Plan it without the scope clause first; keep that only if a clause
112
+ // standing in for the scope is on the query.
113
+ const unscoped = {
114
+ clauses: kept,
115
+ search: words,
116
+ sort,
117
+ base: [
118
+ ...base
119
+ ]
120
+ };
121
+ const trial = planListQuery(reader, unscoped, nameSearchNormalizers);
122
+ return trial.served.some(impliesScope) ? unscoped : scoped;
123
+ }, [
124
+ asked,
125
+ sort,
126
+ base,
127
+ visibleTo,
128
+ foldsScope,
129
+ impliesScope,
130
+ reader
131
+ ]);
132
+ const result = useListQuery(_extends({
133
+ collection: ref,
134
+ declaration: reader,
135
+ request,
136
+ deps: [
137
+ (_ref = scope == null ? void 0 : scope[0]) != null ? _ref : null,
138
+ (_ref1 = scope == null ? void 0 : scope[1]) != null ? _ref1 : null,
139
+ collection,
140
+ readable
141
+ ],
142
+ idField: '$id'
143
+ }, pageSize ? {
144
+ pageSize
145
+ } : {}));
146
+ /*
147
+ * What the list says about it: the solo clauses refused before planning,
148
+ * and the collaborator's prefix read back as the search it stands for.
149
+ */ const plan = useMemo(()=>{
150
+ const prefix = asked.prefixClause;
151
+ const isPrefix = (clause)=>Boolean(prefix) && clause !== 'search' && clause.field === (prefix == null ? void 0 : prefix.field) && clause.op === (prefix == null ? void 0 : prefix.op) && clause.value === (prefix == null ? void 0 : prefix.value);
152
+ const refused = [
153
+ ...asked.refused,
154
+ ...result.plan.refused
155
+ ].map((entry)=>isPrefix(entry.clause) ? {
156
+ clause: 'search',
157
+ reason: entry.reason
158
+ } : entry);
159
+ const servedPrefix = prefix && result.plan.served.some((clause)=>isPrefix(clause));
160
+ const notices = servedPrefix && collaboratorPrefix ? [
161
+ ...result.plan.notices,
162
+ collaboratorPrefix.notice
163
+ ] : result.plan.notices;
164
+ return !prefix && !asked.refused.length ? result.plan : _extends({}, result.plan, {
165
+ refused,
166
+ notices
167
+ });
168
+ }, [
169
+ asked,
170
+ result.plan,
171
+ collaboratorPrefix
172
+ ]);
173
+ return useMemo(()=>_extends({}, result, {
174
+ plan
175
+ }), [
176
+ result,
177
+ plan
178
+ ]);
179
+ }
180
+ export default useCrmListQuery;
181
+
182
+ //# sourceMappingURL=use-crm-list-query.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-crm-list-query.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 type { ListFilterField, ListFilterRequest } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport { nameSearchNormalizers } from '@aglyn/aglyn/app-utils/name-search'\nimport {\n type ListQueryDeclaration,\n type ListQueryFilter,\n type ListQuerySort,\n planListQuery,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport { useFirestore, useScopeTokens } from '@aglyn/tenant-feature-instance'\nimport {\n useListQuery,\n type UseListQueryResult,\n} from '@aglyn/tenant-feature-instance/hooks/use-list-query'\nimport { collection as collectionRef } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { crmListBase, crmListDeclarationFor } from '../model/crm-list-query'\nimport { crmScopeListable } from './use-crm-scope'\n\n/**\n * Whether this reader's CRM queries may drop the `visibleTo` clause — fold\n * the search into it, or stand an array clause in its place (AGL-3321).\n *\n * At the organization level there is no clause to drop. Under a site the\n * rules prove a query without one only for an ORG-WIDE member, so the\n * member's reach is read, and until it has answered the answer is no: a\n * search refused for a second is better than a list the rules refuse.\n */\nexport function useCrmFoldsScope(\n orgId: string | null | undefined,\n visibleTo: readonly string[] | null,\n): boolean {\n const reach = useScopeTokens(visibleTo && orgId ? orgId : undefined)\n return visibleTo === null || (reach.loaded && reach.orgWide)\n}\n\nexport interface UseCrmListQueryOptions {\n /** The org root, or `null` until it is known. */\n scope: readonly [string, string] | null\n /** The collection under the org root. */\n collection: string\n /** The reader's scope tokens, or `null` at the organization level. */\n visibleTo: readonly string[] | null\n /** Whether the reader may run queries without the scope clause — see `useCrmFoldsScope`. */\n foldsScope: boolean\n declaration: ListQueryDeclaration\n clauses: readonly ListFilterRequest[]\n search?: readonly string[]\n sort?: ListQuerySort | null\n /** Predicates beside the scope the list always applies (a task view, a pipeline). */\n base?: readonly ListQueryFilter[]\n /**\n * A clause that IMPLIES the scope clause: one no record outside the\n * reader's scope can answer (a contact's facet key for the viewing\n * holder). When the reader may run a query without the scope clause\n * (`foldsScope`) and such a clause reaches the query, it stands in the\n * scope clause's place — the one array clause a query has is then spent\n * on it. Only when it is actually SERVED: a scope dropped for a clause the\n * plan then refused would list records outside the scope.\n */\n impliesScope?: (clause: ListFilterRequest) => boolean\n /**\n * A reader whose access is some sites keeps the scope clause, which is the\n * query's one array clause, so their search cannot be a token search —\n * folded or not, the rules prove it only from `visibleTo`. It is served\n * instead as a PREFIX of this text field beside the scope clause (a range\n * over its `lowerPath`), and `notice` says so. Absent, their search is\n * refused by name.\n */\n prefixSearch?: { field: ListFilterField; notice: string }\n /**\n * A clause that stands ALONE beside the scope: a range that orders the\n * list by its own field, served without a composite per other clause. A\n * solo clause set beside any other, or beside the search, is refused by\n * name rather than asked.\n */\n soloClause?: (clause: ListFilterRequest) => boolean\n /** `false` holds the query — no read until the list has what it needs. */\n enabled?: boolean\n pageSize?: number\n}\n\n/**\n * A CRM list on its query (AGL-3321): the scope clause as the plan's base,\n * the reader's declaration (`crmListDeclarationFor`), every clause and the\n * search word planned together and paged by `useListQuery`.\n */\nexport function useCrmListQuery<T>(options: UseCrmListQueryOptions): UseListQueryResult<T> {\n const firestore = useFirestore()\n const {\n scope,\n collection,\n visibleTo,\n foldsScope,\n declaration,\n clauses,\n search = [],\n sort = null,\n base = [],\n impliesScope,\n prefixSearch,\n soloClause,\n enabled = true,\n pageSize,\n } = options\n const readable = enabled && Boolean(scope) && crmScopeListable(visibleTo)\n const ref = useMemo(\n () => (readable && scope ? collectionRef(firestore, scope[0], scope[1], collection) : null),\n [readable, firestore, scope, collection],\n )\n // A collaborator's search, as the prefix range their rules can prove.\n const collaboratorPrefix =\n visibleTo && !foldsScope && prefixSearch && search.some((word) => word.trim())\n ? prefixSearch\n : null\n const reader = useMemo(() => {\n const own = crmListDeclarationFor(declaration, foldsScope)\n return collaboratorPrefix\n ? { ...own, fields: [...own.fields, collaboratorPrefix.field] }\n : own\n }, [declaration, foldsScope, collaboratorPrefix])\n const asked = useMemo(() => {\n const prefixClause: ListFilterRequest | null = collaboratorPrefix\n ? {\n field: collaboratorPrefix.field.column,\n op: 'startsWith',\n value: search.find((word) => word.trim())?.trim() ?? '',\n }\n : null\n const all = prefixClause ? [...clauses, prefixClause] : [...clauses]\n const searching = !prefixClause && search.some((word) => word.trim())\n const alone = (clause: ListFilterRequest) =>\n Boolean(soloClause?.(clause)) && (all.length > 1 || searching)\n return {\n clauses: all.filter((clause) => !alone(clause)),\n search: prefixClause ? [] : search,\n refused: all\n .filter(alone)\n .map((clause) => ({\n clause,\n reason:\n 'it orders the list by its own field, so it stands alone — clear the other filters and the search to use it',\n })),\n prefixClause,\n }\n }, [clauses, search, collaboratorPrefix, soloClause])\n const request = useMemo(() => {\n const { clauses: kept, search: words } = asked\n const scoped = { clauses: kept, search: words, sort, base: [...crmListBase(visibleTo), ...base] }\n if (!visibleTo || !foldsScope || !impliesScope || !kept.some(impliesScope)) return scoped\n // Plan it without the scope clause first; keep that only if a clause\n // standing in for the scope is on the query.\n const unscoped = { clauses: kept, search: words, sort, base: [...base] }\n const trial = planListQuery(reader, unscoped, nameSearchNormalizers)\n return trial.served.some(impliesScope) ? unscoped : scoped\n }, [asked, sort, base, visibleTo, foldsScope, impliesScope, reader])\n const result = useListQuery<T>({\n collection: ref,\n declaration: reader,\n request,\n deps: [scope?.[0] ?? null, scope?.[1] ?? null, collection, readable],\n idField: '$id',\n ...(pageSize ? { pageSize } : {}),\n })\n /*\n * What the list says about it: the solo clauses refused before planning,\n * and the collaborator's prefix read back as the search it stands for.\n */\n const plan = useMemo(() => {\n const prefix = asked.prefixClause\n const isPrefix = (clause: ListFilterRequest | 'search') =>\n Boolean(prefix) &&\n clause !== 'search' &&\n clause.field === prefix?.field &&\n clause.op === prefix?.op &&\n clause.value === prefix?.value\n const refused = [...asked.refused, ...result.plan.refused].map((entry) =>\n isPrefix(entry.clause) ? { clause: 'search' as const, reason: entry.reason } : entry,\n )\n const servedPrefix = prefix && result.plan.served.some((clause) => isPrefix(clause))\n const notices = servedPrefix && collaboratorPrefix\n ? [...result.plan.notices, collaboratorPrefix.notice]\n : result.plan.notices\n return !prefix && !asked.refused.length ? result.plan : { ...result.plan, refused, notices }\n }, [asked, result.plan, collaboratorPrefix])\n return useMemo(() => ({ ...result, plan }), [result, plan])\n}\n\nexport default useCrmListQuery\n"],"names":["nameSearchNormalizers","planListQuery","useFirestore","useScopeTokens","useListQuery","collection","collectionRef","useMemo","crmListBase","crmListDeclarationFor","crmScopeListable","useCrmFoldsScope","orgId","visibleTo","reach","undefined","loaded","orgWide","useCrmListQuery","options","firestore","scope","foldsScope","declaration","clauses","search","sort","base","impliesScope","prefixSearch","soloClause","enabled","pageSize","readable","Boolean","ref","collaboratorPrefix","some","word","trim","reader","own","fields","field","asked","prefixClause","column","op","value","find","all","searching","alone","clause","length","filter","refused","map","reason","request","kept","words","scoped","unscoped","trial","served","result","deps","idField","plan","prefix","isPrefix","entry","servedPrefix","notices","notice"],"mappings":"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"}
@@ -15,6 +15,8 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import { type CrmTask } from '@aglyn/aglyn';
18
+ import type { ListFilterRequest } from '@aglyn/shared-ui-jsx/const/list-filter';
19
+ import type { ListQueryPlan } from '@aglyn/shared-ui-jsx/const/list-query-plan';
18
20
  import { type CrmTaskView } from '../model/task-views';
19
21
  /** A task as the console lists it: the document plus its id. */
20
22
  export type CrmTaskRow = CrmTask & {
@@ -41,14 +43,26 @@ export interface CrmTaskListResult {
41
43
  /** The reader's tokens, or `null` at the organization level — no clause (AGL-2630). */
42
44
  readTokens: readonly string[] | null;
43
45
  }
46
+ /** One page of the tasks list, and what its query held. */
47
+ export interface CrmTaskPageResult extends CrmTaskListResult {
48
+ /** What the query holds, what it refused and why (AGL-3321). */
49
+ plan: ListQueryPlan;
50
+ hasMore: boolean;
51
+ page: number;
52
+ setPage: (page: number) => void;
53
+ pageSize: number;
54
+ setPageSize: (pageSize: number) => void;
55
+ }
44
56
  /**
45
- * One view of the tasks section as a live, bounded listener.
57
+ * One view of the tasks section, PAGED BY ITS QUERY (AGL-3321).
46
58
  *
47
59
  * The plan comes from `crmTaskViewPlan`, so the hook holds no opinion about
48
- * what "today" means; it translates a plan into constraints in the order the
49
- * `crmTasks` indexes are declared and caps the window. "My tasks" opens no
50
- * listener at all until the reader's uid is known — a query for
51
- * `assigneeUid == ''` would be a real read that could only answer nothing.
60
+ * what "today" means; the view's predicates are the query's base beside the
61
+ * scope clause, and Kind, Priority, Assignee and the search word are planned
62
+ * onto the same query — every page a page of matches, none narrowed in
63
+ * memory. "My tasks" opens no listener until the reader's uid is known — a
64
+ * query for `assigneeUid == ''` would be a real read that could only answer
65
+ * nothing.
52
66
  */
53
67
  export declare function useCrmTaskList(options: {
54
68
  /** The site the list is read under, or `null` at the organization level. */
@@ -57,7 +71,10 @@ export declare function useCrmTaskList(options: {
57
71
  view: CrmTaskView;
58
72
  uid: string | null | undefined;
59
73
  nowMs: number;
60
- }): CrmTaskListResult;
74
+ /** The Filters panel's clauses, the view's own excluded. */
75
+ clauses?: readonly ListFilterRequest[];
76
+ search?: readonly string[];
77
+ }): CrmTaskPageResult;
61
78
  /**
62
79
  * How many of one record's tasks a card reads. A person with more than fifty
63
80
  * open tasks about one contact has a different problem than a short list.