@open-mercato/core 0.6.1-develop.3260.1.dc719cac84 → 0.6.1-develop.3278.1.a4d26475e4

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 (212) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/domain_mapping/index.js +31 -0
  3. package/dist/generated/entities/domain_mapping/index.js.map +7 -0
  4. package/dist/generated/entities.ids.generated.js +2 -1
  5. package/dist/generated/entities.ids.generated.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +16 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/helpers/integration/salesUi.js +18 -17
  9. package/dist/helpers/integration/salesUi.js.map +2 -2
  10. package/dist/modules/customer_accounts/acl.js +2 -1
  11. package/dist/modules/customer_accounts/acl.js.map +2 -2
  12. package/dist/modules/customer_accounts/api/admin/domain-mappings/[id]/health-check.js +110 -0
  13. package/dist/modules/customer_accounts/api/admin/domain-mappings/[id]/health-check.js.map +7 -0
  14. package/dist/modules/customer_accounts/api/admin/domain-mappings/[id]/verify.js +114 -0
  15. package/dist/modules/customer_accounts/api/admin/domain-mappings/[id]/verify.js.map +7 -0
  16. package/dist/modules/customer_accounts/api/admin/domain-mappings.js +271 -0
  17. package/dist/modules/customer_accounts/api/admin/domain-mappings.js.map +7 -0
  18. package/dist/modules/customer_accounts/api/domain-check.js +70 -0
  19. package/dist/modules/customer_accounts/api/domain-check.js.map +7 -0
  20. package/dist/modules/customer_accounts/api/domain-resolve/all.js +128 -0
  21. package/dist/modules/customer_accounts/api/domain-resolve/all.js.map +7 -0
  22. package/dist/modules/customer_accounts/api/domain-resolve.js +84 -0
  23. package/dist/modules/customer_accounts/api/domain-resolve.js.map +7 -0
  24. package/dist/modules/customer_accounts/api/login.js +14 -3
  25. package/dist/modules/customer_accounts/api/login.js.map +2 -2
  26. package/dist/modules/customer_accounts/api/magic-link/request.js +14 -3
  27. package/dist/modules/customer_accounts/api/magic-link/request.js.map +2 -2
  28. package/dist/modules/customer_accounts/api/password/reset-request.js +14 -3
  29. package/dist/modules/customer_accounts/api/password/reset-request.js.map +2 -2
  30. package/dist/modules/customer_accounts/api/signup.js +32 -5
  31. package/dist/modules/customer_accounts/api/signup.js.map +2 -2
  32. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Actions.js +48 -0
  33. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Actions.js.map +7 -0
  34. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js +43 -0
  35. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.js.map +7 -0
  36. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/DnsConfig.js +53 -0
  37. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/DnsConfig.js.map +7 -0
  38. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/DomainPanel.js +69 -0
  39. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/DomainPanel.js.map +7 -0
  40. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/OrgSwitcher.js +31 -0
  41. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/OrgSwitcher.js.map +7 -0
  42. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/RegisterDialog.js +147 -0
  43. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/RegisterDialog.js.map +7 -0
  44. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/StatusBadgeSection.js +39 -0
  45. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/StatusBadgeSection.js.map +7 -0
  46. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Stepper.js +60 -0
  47. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Stepper.js.map +7 -0
  48. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/domainStatusMap.js +11 -0
  49. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/domainStatusMap.js.map +7 -0
  50. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/types.js +1 -0
  51. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/components/types.js.map +7 -0
  52. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/page.js +361 -0
  53. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/page.js.map +7 -0
  54. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/page.meta.js +28 -0
  55. package/dist/modules/customer_accounts/backend/customer_accounts/settings/domain/page.meta.js.map +7 -0
  56. package/dist/modules/customer_accounts/data/enrichers.js +78 -12
  57. package/dist/modules/customer_accounts/data/enrichers.js.map +2 -2
  58. package/dist/modules/customer_accounts/data/entities.js +85 -2
  59. package/dist/modules/customer_accounts/data/entities.js.map +2 -2
  60. package/dist/modules/customer_accounts/data/guards.js +130 -0
  61. package/dist/modules/customer_accounts/data/guards.js.map +7 -0
  62. package/dist/modules/customer_accounts/data/validators.js +19 -0
  63. package/dist/modules/customer_accounts/data/validators.js.map +2 -2
  64. package/dist/modules/customer_accounts/di.js +23 -1
  65. package/dist/modules/customer_accounts/di.js.map +2 -2
  66. package/dist/modules/customer_accounts/events.js +9 -2
  67. package/dist/modules/customer_accounts/events.js.map +2 -2
  68. package/dist/modules/customer_accounts/lib/customerAuthServer.js +27 -7
  69. package/dist/modules/customer_accounts/lib/customerAuthServer.js.map +2 -2
  70. package/dist/modules/customer_accounts/lib/customerUrl.js +48 -0
  71. package/dist/modules/customer_accounts/lib/customerUrl.js.map +7 -0
  72. package/dist/modules/customer_accounts/lib/hostname.js +55 -0
  73. package/dist/modules/customer_accounts/lib/hostname.js.map +7 -0
  74. package/dist/modules/customer_accounts/lib/platformDomains.js +7 -0
  75. package/dist/modules/customer_accounts/lib/platformDomains.js.map +7 -0
  76. package/dist/modules/customer_accounts/lib/proxyRanges.js +71 -0
  77. package/dist/modules/customer_accounts/lib/proxyRanges.js.map +7 -0
  78. package/dist/modules/customer_accounts/lib/rateLimiter.js +7 -0
  79. package/dist/modules/customer_accounts/lib/rateLimiter.js.map +2 -2
  80. package/dist/modules/customer_accounts/lib/resolveTenantContext.js +63 -0
  81. package/dist/modules/customer_accounts/lib/resolveTenantContext.js.map +7 -0
  82. package/dist/modules/customer_accounts/lib/secretCompare.js +13 -0
  83. package/dist/modules/customer_accounts/lib/secretCompare.js.map +7 -0
  84. package/dist/modules/customer_accounts/migrations/Migration20260430120000_customer_accounts.js +37 -0
  85. package/dist/modules/customer_accounts/migrations/Migration20260430120000_customer_accounts.js.map +7 -0
  86. package/dist/modules/customer_accounts/notifications.client.js +76 -0
  87. package/dist/modules/customer_accounts/notifications.client.js.map +2 -2
  88. package/dist/modules/customer_accounts/notifications.js +76 -0
  89. package/dist/modules/customer_accounts/notifications.js.map +2 -2
  90. package/dist/modules/customer_accounts/services/domainMappingService.js +497 -0
  91. package/dist/modules/customer_accounts/services/domainMappingService.js.map +7 -0
  92. package/dist/modules/customer_accounts/setup.js +48 -1
  93. package/dist/modules/customer_accounts/setup.js.map +2 -2
  94. package/dist/modules/customer_accounts/subscribers/invalidateDomainCache.js +29 -0
  95. package/dist/modules/customer_accounts/subscribers/invalidateDomainCache.js.map +7 -0
  96. package/dist/modules/customer_accounts/subscribers/notifyDomainActivated.js +29 -0
  97. package/dist/modules/customer_accounts/subscribers/notifyDomainActivated.js.map +7 -0
  98. package/dist/modules/customer_accounts/subscribers/notifyDomainDnsFailed.js +30 -0
  99. package/dist/modules/customer_accounts/subscribers/notifyDomainDnsFailed.js.map +7 -0
  100. package/dist/modules/customer_accounts/subscribers/notifyDomainTlsFailed.js +31 -0
  101. package/dist/modules/customer_accounts/subscribers/notifyDomainTlsFailed.js.map +7 -0
  102. package/dist/modules/customer_accounts/subscribers/notifyDomainVerified.js +29 -0
  103. package/dist/modules/customer_accounts/subscribers/notifyDomainVerified.js.map +7 -0
  104. package/dist/modules/customer_accounts/workers/domainTlsRetryWorker.js +67 -0
  105. package/dist/modules/customer_accounts/workers/domainTlsRetryWorker.js.map +7 -0
  106. package/dist/modules/customer_accounts/workers/domainVerificationWorker.js +34 -0
  107. package/dist/modules/customer_accounts/workers/domainVerificationWorker.js.map +7 -0
  108. package/dist/modules/customers/api/companies/route.js +4 -9
  109. package/dist/modules/customers/api/companies/route.js.map +2 -2
  110. package/dist/modules/customers/api/deals/route.js +4 -9
  111. package/dist/modules/customers/api/deals/route.js.map +2 -2
  112. package/dist/modules/customers/api/people/route.js +4 -9
  113. package/dist/modules/customers/api/people/route.js.map +2 -2
  114. package/dist/modules/customers/api/utils.js +0 -12
  115. package/dist/modules/customers/api/utils.js.map +2 -2
  116. package/dist/modules/customers/backend/customers/companies/page.js +361 -284
  117. package/dist/modules/customers/backend/customers/companies/page.js.map +2 -2
  118. package/dist/modules/customers/backend/customers/deals/page.js +378 -473
  119. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  120. package/dist/modules/customers/backend/customers/people/page.js +368 -286
  121. package/dist/modules/customers/backend/customers/people/page.js.map +2 -2
  122. package/dist/modules/customers/components/detail/assignableStaff.js +15 -1
  123. package/dist/modules/customers/components/detail/assignableStaff.js.map +2 -2
  124. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js +188 -36
  125. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js.map +2 -2
  126. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.js +2 -2
  127. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.js.map +2 -2
  128. package/dist/modules/customers/widgets/injection-table.js +6 -0
  129. package/dist/modules/customers/widgets/injection-table.js.map +2 -2
  130. package/dist/modules/perspectives/services/perspectiveService.js +16 -2
  131. package/dist/modules/perspectives/services/perspectiveService.js.map +2 -2
  132. package/dist/modules/portal/frontend/[orgSlug]/portal/login/page.js +1 -0
  133. package/dist/modules/portal/frontend/[orgSlug]/portal/login/page.js.map +2 -2
  134. package/dist/modules/query_index/lib/engine.js +31 -8
  135. package/dist/modules/query_index/lib/engine.js.map +2 -2
  136. package/generated/entities/domain_mapping/index.ts +14 -0
  137. package/generated/entities.ids.generated.ts +2 -1
  138. package/generated/entity-fields-registry.ts +16 -0
  139. package/package.json +5 -5
  140. package/src/helpers/integration/salesUi.ts +27 -23
  141. package/src/modules/customer_accounts/acl.ts +1 -0
  142. package/src/modules/customer_accounts/api/admin/domain-mappings/[id]/health-check.ts +119 -0
  143. package/src/modules/customer_accounts/api/admin/domain-mappings/[id]/verify.ts +123 -0
  144. package/src/modules/customer_accounts/api/admin/domain-mappings.ts +303 -0
  145. package/src/modules/customer_accounts/api/domain-check.ts +83 -0
  146. package/src/modules/customer_accounts/api/domain-resolve/all.ts +139 -0
  147. package/src/modules/customer_accounts/api/domain-resolve.ts +91 -0
  148. package/src/modules/customer_accounts/api/login.ts +14 -3
  149. package/src/modules/customer_accounts/api/magic-link/request.ts +14 -3
  150. package/src/modules/customer_accounts/api/password/reset-request.ts +15 -3
  151. package/src/modules/customer_accounts/api/signup.ts +36 -5
  152. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Actions.tsx +70 -0
  153. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Diagnostics.tsx +66 -0
  154. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/DnsConfig.tsx +78 -0
  155. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/DomainPanel.tsx +90 -0
  156. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/OrgSwitcher.tsx +43 -0
  157. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/RegisterDialog.tsx +178 -0
  158. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/StatusBadgeSection.tsx +56 -0
  159. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/Stepper.tsx +69 -0
  160. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/domainStatusMap.ts +10 -0
  161. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/components/types.ts +31 -0
  162. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/page.meta.ts +26 -0
  163. package/src/modules/customer_accounts/backend/customer_accounts/settings/domain/page.tsx +411 -0
  164. package/src/modules/customer_accounts/data/enrichers.ts +95 -12
  165. package/src/modules/customer_accounts/data/entities.ts +80 -1
  166. package/src/modules/customer_accounts/data/guards.ts +155 -0
  167. package/src/modules/customer_accounts/data/validators.ts +26 -0
  168. package/src/modules/customer_accounts/di.ts +35 -1
  169. package/src/modules/customer_accounts/events.ts +8 -1
  170. package/src/modules/customer_accounts/i18n/de.json +78 -0
  171. package/src/modules/customer_accounts/i18n/en.json +78 -0
  172. package/src/modules/customer_accounts/i18n/es.json +78 -0
  173. package/src/modules/customer_accounts/i18n/pl.json +78 -0
  174. package/src/modules/customer_accounts/lib/customerAuthServer.ts +44 -8
  175. package/src/modules/customer_accounts/lib/customerUrl.ts +72 -0
  176. package/src/modules/customer_accounts/lib/hostname.ts +76 -0
  177. package/src/modules/customer_accounts/lib/platformDomains.ts +14 -0
  178. package/src/modules/customer_accounts/lib/proxyRanges.ts +85 -0
  179. package/src/modules/customer_accounts/lib/rateLimiter.ts +10 -0
  180. package/src/modules/customer_accounts/lib/resolveTenantContext.ts +96 -0
  181. package/src/modules/customer_accounts/lib/secretCompare.ts +20 -0
  182. package/src/modules/customer_accounts/migrations/.snapshot-open-mercato.json +341 -0
  183. package/src/modules/customer_accounts/migrations/Migration20260430120000_customer_accounts.ts +38 -0
  184. package/src/modules/customer_accounts/notifications.client.ts +76 -0
  185. package/src/modules/customer_accounts/notifications.ts +76 -0
  186. package/src/modules/customer_accounts/services/domainMappingService.ts +632 -0
  187. package/src/modules/customer_accounts/setup.ts +81 -1
  188. package/src/modules/customer_accounts/subscribers/invalidateDomainCache.ts +35 -0
  189. package/src/modules/customer_accounts/subscribers/notifyDomainActivated.ts +32 -0
  190. package/src/modules/customer_accounts/subscribers/notifyDomainDnsFailed.ts +33 -0
  191. package/src/modules/customer_accounts/subscribers/notifyDomainTlsFailed.ts +34 -0
  192. package/src/modules/customer_accounts/subscribers/notifyDomainVerified.ts +32 -0
  193. package/src/modules/customer_accounts/workers/domainTlsRetryWorker.ts +84 -0
  194. package/src/modules/customer_accounts/workers/domainVerificationWorker.ts +45 -0
  195. package/src/modules/customers/api/companies/route.ts +4 -9
  196. package/src/modules/customers/api/deals/route.ts +4 -9
  197. package/src/modules/customers/api/people/route.ts +4 -9
  198. package/src/modules/customers/api/utils.ts +0 -14
  199. package/src/modules/customers/backend/customers/companies/page.tsx +321 -237
  200. package/src/modules/customers/backend/customers/deals/page.tsx +321 -455
  201. package/src/modules/customers/backend/customers/people/page.tsx +331 -240
  202. package/src/modules/customers/components/detail/assignableStaff.ts +21 -0
  203. package/src/modules/customers/i18n/de.json +31 -0
  204. package/src/modules/customers/i18n/en.json +31 -0
  205. package/src/modules/customers/i18n/es.json +31 -0
  206. package/src/modules/customers/i18n/pl.json +31 -0
  207. package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.tsx +197 -40
  208. package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.ts +13 -7
  209. package/src/modules/customers/widgets/injection-table.ts +6 -0
  210. package/src/modules/perspectives/services/perspectiveService.ts +30 -2
  211. package/src/modules/portal/frontend/[orgSlug]/portal/login/page.tsx +1 -0
  212. package/src/modules/query_index/lib/engine.ts +46 -8
@@ -7,9 +7,10 @@ import { useQueryClient } from '@tanstack/react-query'
7
7
  import type { ColumnDef } from '@tanstack/react-table'
8
8
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
9
9
  import { DataTable, type DataTableExportFormat, withDataTableNamespaces } from '@open-mercato/ui/backend/DataTable'
10
- import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
11
- import type { AdvancedFilterState } from '@open-mercato/shared/lib/query/advanced-filter'
12
- import { deserializeAdvancedFilter, serializeAdvancedFilter } from '@open-mercato/shared/lib/query/advanced-filter'
10
+ import type { AdvancedFilterTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'
11
+ import { createEmptyTree, makeRuleTree, makeMultiRuleTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'
12
+ import { deserializeTree, deserializeAdvancedFilter, flatToTree, mapDictionaryColorToTone, serializeTree, type FilterFieldDef, type FilterOption as AdvancedFilterOption } from '@open-mercato/shared/lib/query/advanced-filter'
13
+ import { useCurrentUserId } from '@open-mercato/ui/backend/utils/useCurrentUserId'
13
14
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
14
15
  import { buildCrudExportUrl, deleteCrud } from '@open-mercato/ui/backend/utils/crud'
15
16
  import { groupBulkDeleteFailures, runBulkDelete } from '@open-mercato/ui/backend/utils/bulkDelete'
@@ -40,6 +41,53 @@ import {
40
41
  supportsCustomFieldColumn,
41
42
  } from '@open-mercato/ui/backend/utils/customFieldColumns'
42
43
  import { CollectionPreviewCell, normalizeCollectionLabels } from '../../../components/list/CollectionPreviewCell'
44
+ import { useAutoDiscoveredFields } from '@open-mercato/ui/backend/utils/useAutoDiscoveredFields'
45
+ import { useAdvancedFilterTree } from '@open-mercato/ui/backend/hooks/useAdvancedFilter'
46
+ import { AdvancedFilterPanel } from '@open-mercato/ui/backend/filters/AdvancedFilterPanel'
47
+ import { ActiveFilterChips } from '@open-mercato/ui/backend/filters/ActiveFilterChips'
48
+ import type { FilterPreset } from '@open-mercato/ui/backend/filters/QuickFilters'
49
+ import {
50
+ ensureCurrentUserFilterOption,
51
+ fetchAssignableStaffMembers,
52
+ mapAssignableStaffToFilterOptions,
53
+ } from '../../../components/detail/assignableStaff'
54
+
55
+ function makeDealsPresets(): FilterPreset[] {
56
+ return [
57
+ {
58
+ id: 'my-deals',
59
+ labelKey: 'customers.deals.presets.myDeals',
60
+ requiresUser: true,
61
+ build: ({ userId }) => makeRuleTree({ field: 'owner_user_id', operator: 'is', value: userId }),
62
+ },
63
+ {
64
+ id: 'closing-month',
65
+ labelKey: 'customers.deals.presets.closingMonth',
66
+ iconName: 'clock',
67
+ build: ({ now }) => {
68
+ const start = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10)
69
+ const end = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10)
70
+ return makeRuleTree({ field: 'expected_close_at', operator: 'between', value: [start, end] })
71
+ },
72
+ },
73
+ // The Deal entity has no dedicated "at risk" or health-score field — `customer_deals`
74
+ // exposes only `status` (open/win/loose/closed/in_progress, dictionary-driven) and
75
+ // `closure_outcome`. Rather than fabricate a mapping, the "At risk" preset is omitted
76
+ // until the data model exposes a first-class signal.
77
+ {
78
+ id: 'won-quarter',
79
+ labelKey: 'customers.deals.presets.wonQuarter',
80
+ build: ({ now }) => {
81
+ const quarter = Math.floor(now.getMonth() / 3)
82
+ const start = new Date(now.getFullYear(), quarter * 3, 1).toISOString().slice(0, 10)
83
+ return makeMultiRuleTree([
84
+ { field: 'status', operator: 'is', value: 'win' },
85
+ { field: 'expected_close_at', operator: 'is_after', value: start },
86
+ ], 'and')
87
+ },
88
+ },
89
+ ]
90
+ }
43
91
 
44
92
  type DealRow = {
45
93
  id: string
@@ -53,6 +101,7 @@ type DealRow = {
53
101
  probability?: number | null
54
102
  expectedCloseAt?: string | null
55
103
  updatedAt?: string | null
104
+ ownerUserId?: string | null
56
105
  companies: { id: string; label: string }[]
57
106
  people: { id: string; label: string }[]
58
107
  } & Record<string, unknown>
@@ -64,57 +113,10 @@ type DealsResponse = {
64
113
  }
65
114
 
66
115
  type FilterOption = { value: string; label: string }
116
+ type DictionaryOptionWithTone = AdvancedFilterOption & FilterOption
67
117
 
68
118
  type DictionaryKey = Extract<CustomerDictionaryKind, 'deal-statuses' | 'pipeline-stages'>
69
119
 
70
- type PersonLookupRecord = {
71
- id: string
72
- name: string | null
73
- email: string | null
74
- phone: string | null
75
- }
76
-
77
- type CompanyLookupRecord = {
78
- id: string
79
- name: string | null
80
- domain: string | null
81
- email: string | null
82
- }
83
-
84
- function parsePersonLookupRecord(item: unknown): PersonLookupRecord | null {
85
- if (typeof item !== 'object' || item === null) return null
86
- const record = item as Record<string, unknown>
87
- const id = typeof record.id === 'string' ? record.id : null
88
- if (!id || !isUuid(id)) return null
89
- const name = typeof record.display_name === 'string' ? record.display_name : null
90
- const email = typeof record.primary_email === 'string' ? record.primary_email : null
91
- const phone = typeof record.primary_phone === 'string' ? record.primary_phone : null
92
- return { id, name, email, phone }
93
- }
94
-
95
- function parseCompanyLookupRecord(item: unknown): CompanyLookupRecord | null {
96
- if (typeof item !== 'object' || item === null) return null
97
- const record = item as Record<string, unknown>
98
- const id = typeof record.id === 'string' ? record.id : null
99
- if (!id || !isUuid(id)) return null
100
- const name = typeof record.display_name === 'string' ? record.display_name : null
101
- const domain = typeof record.primary_domain === 'string' ? record.primary_domain : null
102
- const email = typeof record.primary_email === 'string' ? record.primary_email : null
103
- return { id, name, domain, email }
104
- }
105
-
106
- type OptionsState = {
107
- options: FilterOption[]
108
- idToLabel: Record<string, string>
109
- labelToId: Record<string, string>
110
- }
111
-
112
- const EMPTY_OPTIONS_STATE: OptionsState = {
113
- options: [],
114
- idToLabel: {},
115
- labelToId: {},
116
- }
117
-
118
120
  const PAGE_SIZE = 20
119
121
  const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
120
122
 
@@ -143,106 +145,6 @@ function extractIdsFromParams(params: URLSearchParams | null | undefined, key: s
143
145
  return normalizeIdCandidates(values)
144
146
  }
145
147
 
146
- function ensureUniqueLabel(base: string, occupied: Set<string>): string {
147
- const trimmed = base.trim() || 'Unnamed'
148
- if (!occupied.has(trimmed)) {
149
- occupied.add(trimmed)
150
- return trimmed
151
- }
152
- let counter = 2
153
- let candidate = `${trimmed} • ${counter}`
154
- while (occupied.has(candidate)) {
155
- counter += 1
156
- candidate = `${trimmed} • ${counter}`
157
- }
158
- occupied.add(candidate)
159
- return candidate
160
- }
161
-
162
- async function fetchPeopleLookup(query?: string): Promise<PersonLookupRecord[]> {
163
- const search = new URLSearchParams()
164
- search.set('page', '1')
165
- search.set('pageSize', '20')
166
- if (query && query.trim().length) search.set('search', query.trim())
167
- try {
168
- const call = await apiCall<{ items?: unknown[] }>(`/api/customers/people?${search.toString()}`)
169
- if (!call.ok) return []
170
- const items = Array.isArray(call.result?.items) ? call.result.items : []
171
- return items
172
- .map((item) => parsePersonLookupRecord(item))
173
- .filter((record): record is PersonLookupRecord => record !== null)
174
- } catch {
175
- return []
176
- }
177
- }
178
-
179
- async function fetchPeopleLookupByIds(ids: string[]): Promise<PersonLookupRecord[]> {
180
- const unique = Array.from(new Set(ids.filter((id) => isUuid(id))))
181
- if (!unique.length) return []
182
- const results = await Promise.all(
183
- unique.map(async (id) => {
184
- const search = new URLSearchParams()
185
- search.set('id', id)
186
- search.set('page', '1')
187
- search.set('pageSize', '1')
188
- try {
189
- const call = await apiCall<{ items?: unknown[] }>(`/api/customers/people?${search.toString()}`)
190
- if (!call.ok) return null
191
- const items = Array.isArray(call.result?.items) ? call.result.items : []
192
- const match = items
193
- .map((item) => parsePersonLookupRecord(item))
194
- .find((record) => record?.id === id)
195
- return match ?? null
196
- } catch {
197
- return null
198
- }
199
- }),
200
- )
201
- return results.filter((record): record is PersonLookupRecord => !!record)
202
- }
203
-
204
- async function fetchCompaniesLookup(query?: string): Promise<CompanyLookupRecord[]> {
205
- const search = new URLSearchParams()
206
- search.set('page', '1')
207
- search.set('pageSize', '20')
208
- if (query && query.trim().length) search.set('search', query.trim())
209
- try {
210
- const call = await apiCall<{ items?: unknown[] }>(`/api/customers/companies?${search.toString()}`)
211
- if (!call.ok) return []
212
- const items = Array.isArray(call.result?.items) ? call.result.items : []
213
- return items
214
- .map((item) => parseCompanyLookupRecord(item))
215
- .filter((record): record is CompanyLookupRecord => record !== null)
216
- } catch {
217
- return []
218
- }
219
- }
220
-
221
- async function fetchCompaniesLookupByIds(ids: string[]): Promise<CompanyLookupRecord[]> {
222
- const unique = Array.from(new Set(ids.filter((id) => isUuid(id))))
223
- if (!unique.length) return []
224
- const results = await Promise.all(
225
- unique.map(async (id) => {
226
- const search = new URLSearchParams()
227
- search.set('id', id)
228
- search.set('page', '1')
229
- search.set('pageSize', '1')
230
- try {
231
- const call = await apiCall<{ items?: unknown[] }>(`/api/customers/companies?${search.toString()}`)
232
- if (!call.ok) return null
233
- const items = Array.isArray(call.result?.items) ? call.result.items : []
234
- const match = items
235
- .map((item) => parseCompanyLookupRecord(item))
236
- .find((record) => record?.id === id)
237
- return match ?? null
238
- } catch {
239
- return null
240
- }
241
- }),
242
- )
243
- return results.filter((record): record is CompanyLookupRecord => !!record)
244
- }
245
-
246
148
  function formatCurrency(amount: number | null | undefined, currency: string | null | undefined, fallback: string): string {
247
149
  if (typeof amount !== 'number' || Number.isNaN(amount)) return fallback
248
150
  try {
@@ -264,14 +166,6 @@ function formatDateValue(value: string | null | undefined, fallback: string): st
264
166
  return date.toLocaleDateString()
265
167
  }
266
168
 
267
- function arraysEqual(a: string[], b: string[]): boolean {
268
- if (a.length !== b.length) return false
269
- for (let i = 0; i < a.length; i += 1) {
270
- if (a[i] !== b[i]) return false
271
- }
272
- return true
273
- }
274
-
275
169
  export default function CustomersDealsPage() {
276
170
  const t = useT()
277
171
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
@@ -294,17 +188,43 @@ export default function CustomersDealsPage() {
294
188
  const [isLoading, setIsLoading] = React.useState(false)
295
189
  const [reloadToken, setReloadToken] = React.useState(0)
296
190
  const [pendingDeleteId, setPendingDeleteId] = React.useState<string | null>(null)
297
- const [filterValues, setFilterValues] = React.useState<FilterValues>({})
298
- const [advancedFilterState, setAdvancedFilterState] = React.useState<AdvancedFilterState>(() => {
299
- const params = searchParams
300
- if (!params) return { logic: 'and', conditions: [] }
191
+ // One-shot URL hydration used as the hook's initial value. The hook is the
192
+ // single source of truth from this point on — the page MUST NOT keep a
193
+ // parallel `useState<AdvancedFilterTree>` (see spec "Migration & Backward
194
+ // Compatibility" → state ownership).
195
+ const initialFilterTree = React.useMemo<AdvancedFilterTree>(() => {
196
+ if (!searchParams) return createEmptyTree()
301
197
  const record: Record<string, string> = {}
302
- params.forEach((value, key) => {
198
+ searchParams.forEach((value, key) => {
303
199
  if (key.startsWith('filter[')) record[key] = value
304
200
  })
305
- const hydrated = deserializeAdvancedFilter(record)
306
- return hydrated ?? { logic: 'and', conditions: [] }
201
+ const v2 = deserializeTree(record)
202
+ if (v2) return v2
203
+ const flat = deserializeAdvancedFilter(record)
204
+ if (flat) return flatToTree(flat)
205
+ return createEmptyTree()
206
+ // eslint-disable-next-line react-hooks/exhaustive-deps
207
+ }, [])
208
+ // `filterPanel` lives at the top of the component so derived state below
209
+ // (URL params, data fetch, export config) can read `filterPanel.appliedTree`
210
+ // directly. Real `FilterFieldDef[]` arrives later from `useAutoDiscoveredFields`
211
+ // (it depends on columns) and is synced into the hook via a small effect at
212
+ // the bottom of the component. The hook reads fields through a ref at
213
+ // validation time only — first validation cannot fire before user input, by
214
+ // which point fields have settled, so the empty initial value is safe.
215
+ const [panelFields, setPanelFields] = React.useState<FilterFieldDef[]>([])
216
+ const [filtersOpen, setFiltersOpen] = React.useState(false)
217
+ const filtersTriggerRef = React.useRef<HTMLButtonElement | null>(null)
218
+ const filterPanel = useAdvancedFilterTree({
219
+ initial: initialFilterTree,
220
+ fields: panelFields,
221
+ onApply: () => setPage(1),
307
222
  })
223
+ const advancedFilterState = filterPanel.appliedTree
224
+ const handleAdvancedFilterClear = React.useCallback(() => {
225
+ filterPanel.clear()
226
+ setPage(1)
227
+ }, [filterPanel])
308
228
  const [cacheStatus, setCacheStatus] = React.useState<'hit' | 'miss' | null>(null)
309
229
 
310
230
  const initialPersonIds = React.useMemo(
@@ -319,147 +239,6 @@ export default function CustomersDealsPage() {
319
239
  const [selectedPersonIds, setSelectedPersonIds] = React.useState<string[]>(initialPersonIds)
320
240
  const [selectedCompanyIds, setSelectedCompanyIds] = React.useState<string[]>(initialCompanyIds)
321
241
 
322
- const [peopleState, setPeopleState] = React.useState<OptionsState>(EMPTY_OPTIONS_STATE)
323
- const [companiesState, setCompaniesState] = React.useState<OptionsState>(EMPTY_OPTIONS_STATE)
324
- const peopleCacheRef = React.useRef<Map<string, FilterOption[]>>(new Map())
325
- const companiesCacheRef = React.useRef<Map<string, FilterOption[]>>(new Map())
326
-
327
- const buildPersonLabel = React.useCallback((record: PersonLookupRecord): string => {
328
- const parts: string[] = []
329
- const name = record.name?.trim()
330
- if (name) parts.push(name)
331
- const email = record.email?.trim()
332
- if (email && !parts.includes(email)) parts.push(email)
333
- const phone = record.phone?.trim()
334
- if (!parts.length && phone) parts.push(phone)
335
- if (!parts.length) parts.push(t('customers.deals.list.unnamedPerson', 'Unnamed person'))
336
- return parts.join(' • ')
337
- }, [t])
338
-
339
- const buildCompanyLabel = React.useCallback((record: CompanyLookupRecord): string => {
340
- const parts: string[] = []
341
- const name = record.name?.trim()
342
- if (name) parts.push(name)
343
- const domain = record.domain?.trim()
344
- if (domain && !parts.includes(domain)) parts.push(domain)
345
- const email = record.email?.trim()
346
- if (!parts.length && email) parts.push(email)
347
- if (!parts.length) parts.push(t('customers.deals.list.unnamedCompany', 'Unnamed company'))
348
- return parts.join(' • ')
349
- }, [t])
350
-
351
- const ingestPeopleRecords = React.useCallback((records: PersonLookupRecord[]) => {
352
- if (!records.length) return [] as FilterOption[]
353
- const queryMap = new Map<string, FilterOption>()
354
- setPeopleState((prev) => {
355
- const idToLabel = { ...prev.idToLabel }
356
- const labelToId: Record<string, string> = {}
357
- const merged = new Map(prev.options.map((opt) => [opt.value, opt]))
358
- const occupied = new Set<string>()
359
- Object.entries(prev.labelToId).forEach(([label, id]) => {
360
- occupied.add(label)
361
- labelToId[label] = id
362
- })
363
- records.forEach((record) => {
364
- if (!isUuid(record.id)) return
365
- const base = buildPersonLabel(record)
366
- let previousLabel = idToLabel[record.id]
367
- if (previousLabel) {
368
- delete labelToId[previousLabel]
369
- occupied.delete(previousLabel)
370
- }
371
- const label = ensureUniqueLabel(base, occupied)
372
- idToLabel[record.id] = label
373
- labelToId[label] = record.id
374
- const option = { value: record.id, label }
375
- merged.set(record.id, option)
376
- queryMap.set(record.id, option)
377
- })
378
- const nextOptions = Array.from(merged.values()).sort((a, b) => a.label.localeCompare(b.label))
379
- return { options: nextOptions, idToLabel, labelToId }
380
- })
381
- return Array.from(queryMap.values()).sort((a, b) => a.label.localeCompare(b.label))
382
- }, [buildPersonLabel])
383
-
384
- const ingestCompanyRecords = React.useCallback((records: CompanyLookupRecord[]) => {
385
- if (!records.length) return [] as FilterOption[]
386
- const queryMap = new Map<string, FilterOption>()
387
- setCompaniesState((prev) => {
388
- const idToLabel = { ...prev.idToLabel }
389
- const labelToId: Record<string, string> = {}
390
- const merged = new Map(prev.options.map((opt) => [opt.value, opt]))
391
- const occupied = new Set<string>()
392
- Object.entries(prev.labelToId).forEach(([label, id]) => {
393
- occupied.add(label)
394
- labelToId[label] = id
395
- })
396
- records.forEach((record) => {
397
- if (!isUuid(record.id)) return
398
- const base = buildCompanyLabel(record)
399
- let previousLabel = idToLabel[record.id]
400
- if (previousLabel) {
401
- delete labelToId[previousLabel]
402
- occupied.delete(previousLabel)
403
- }
404
- const label = ensureUniqueLabel(base, occupied)
405
- idToLabel[record.id] = label
406
- labelToId[label] = record.id
407
- const option = { value: record.id, label }
408
- merged.set(record.id, option)
409
- queryMap.set(record.id, option)
410
- })
411
- const nextOptions = Array.from(merged.values()).sort((a, b) => a.label.localeCompare(b.label))
412
- return { options: nextOptions, idToLabel, labelToId }
413
- })
414
- return Array.from(queryMap.values()).sort((a, b) => a.label.localeCompare(b.label))
415
- }, [buildCompanyLabel])
416
-
417
- const loadPeopleOptions = React.useCallback(async (query?: string) => {
418
- const normalizedQuery = (query || '').trim().toLowerCase()
419
- const cacheKey = `${scopeVersion}|${normalizedQuery}`
420
- const cached = peopleCacheRef.current.get(cacheKey)
421
- if (cached) return cached
422
- const records = await fetchPeopleLookup(query)
423
- const options = ingestPeopleRecords(records)
424
- peopleCacheRef.current.set(cacheKey, options)
425
- return options
426
- }, [scopeVersion, ingestPeopleRecords])
427
-
428
- const loadCompanyOptions = React.useCallback(async (query?: string) => {
429
- const normalizedQuery = (query || '').trim().toLowerCase()
430
- const cacheKey = `${scopeVersion}|${normalizedQuery}`
431
- const cached = companiesCacheRef.current.get(cacheKey)
432
- if (cached) return cached
433
- const records = await fetchCompaniesLookup(query)
434
- const options = ingestCompanyRecords(records)
435
- companiesCacheRef.current.set(cacheKey, options)
436
- return options
437
- }, [scopeVersion, ingestCompanyRecords])
438
-
439
- React.useEffect(() => {
440
- let cancelled = false
441
- if (!selectedPersonIds.length) return
442
- const missing = selectedPersonIds.filter((id) => !peopleState.idToLabel[id])
443
- if (!missing.length) return
444
- fetchPeopleLookupByIds(missing).then((records) => {
445
- if (cancelled) return
446
- ingestPeopleRecords(records)
447
- })
448
- return () => { cancelled = true }
449
- }, [selectedPersonIds, peopleState.idToLabel, ingestPeopleRecords])
450
-
451
- React.useEffect(() => {
452
- let cancelled = false
453
- if (!selectedCompanyIds.length) return
454
- const missing = selectedCompanyIds.filter((id) => !companiesState.idToLabel[id])
455
- if (!missing.length) return
456
- fetchCompaniesLookupByIds(missing).then((records) => {
457
- if (cancelled) return
458
- ingestCompanyRecords(records)
459
- })
460
- return () => { cancelled = true }
461
- }, [selectedCompanyIds, companiesState.idToLabel, ingestCompanyRecords])
462
-
463
242
  const [dictionaryMaps, setDictionaryMaps] = React.useState<Record<DictionaryKey, CustomerDictionaryMap>>({
464
243
  'deal-statuses': {},
465
244
  'pipeline-stages': {},
@@ -488,6 +267,21 @@ export default function CustomersDealsPage() {
488
267
  loadDictionaries().catch(() => {})
489
268
  return () => { cancelled = true }
490
269
  }, [fetchDictionaryEntries, reloadToken])
270
+ const dictionaryOptions = React.useMemo(() => {
271
+ const toOptions = (map?: CustomerDictionaryMap | null): DictionaryOptionWithTone[] =>
272
+ Object.values(map ?? {})
273
+ .map((entry) => {
274
+ const tone = mapDictionaryColorToTone(entry.color)
275
+ const option: DictionaryOptionWithTone = { value: entry.value, label: entry.label }
276
+ if (tone) option.tone = tone
277
+ return option
278
+ })
279
+ .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }))
280
+ return {
281
+ dealStatuses: toOptions(dictionaryMaps['deal-statuses']),
282
+ pipelineStages: toOptions(dictionaryMaps['pipeline-stages']),
283
+ }
284
+ }, [dictionaryMaps])
491
285
 
492
286
  React.useEffect(() => {
493
287
  let cancelled = false
@@ -509,87 +303,11 @@ export default function CustomersDealsPage() {
509
303
  return () => { cancelled = true }
510
304
  }, [reloadToken, scopeVersion])
511
305
 
512
- React.useEffect(() => {
513
- peopleCacheRef.current.clear()
514
- companiesCacheRef.current.clear()
515
- setPeopleState((prev) => {
516
- if (!prev.options.length && !Object.keys(prev.idToLabel).length) return prev
517
- return { ...EMPTY_OPTIONS_STATE }
518
- })
519
- setCompaniesState((prev) => {
520
- if (!prev.options.length && !Object.keys(prev.idToLabel).length) return prev
521
- return { ...EMPTY_OPTIONS_STATE }
522
- })
523
- }, [scopeVersion, reloadToken])
524
-
525
- const syncFilterIds = React.useCallback((
526
- key: 'people' | 'companies',
527
- ids: string[],
528
- ) => {
529
- setFilterValues((prev) => {
530
- const current = Array.isArray(prev[key]) ? (prev[key] as string[]) : []
531
- if (!ids.length) {
532
- if (!current.length) return prev
533
- const next = { ...prev }
534
- delete next[key]
535
- return next
536
- }
537
- if (arraysEqual(current, ids)) return prev
538
- return { ...prev, [key]: [...ids] }
539
- })
540
- }, [])
541
-
542
- React.useEffect(() => {
543
- syncFilterIds('people', selectedPersonIds)
544
- }, [selectedPersonIds, syncFilterIds])
545
-
546
- React.useEffect(() => {
547
- syncFilterIds('companies', selectedCompanyIds)
548
- }, [selectedCompanyIds, syncFilterIds])
549
-
550
306
  const handleSearchChange = React.useCallback((value: string) => {
551
307
  setSearch(value.trim())
552
308
  setPage(1)
553
309
  }, [])
554
310
 
555
- const handleFiltersApply = React.useCallback((values: FilterValues) => {
556
- const next: FilterValues = { ...values }
557
-
558
- const rawPeople = Array.isArray(values.people) ? (values.people as string[]) : []
559
- const nextPersonIds = Array.from(
560
- new Set(
561
- rawPeople
562
- .map((value) => (typeof value === 'string' ? value.trim() : ''))
563
- .filter((value) => value.length > 0 && isUuid(value)),
564
- ),
565
- )
566
- setSelectedPersonIds(nextPersonIds)
567
- if (nextPersonIds.length) next.people = nextPersonIds
568
- else delete next.people
569
-
570
- const rawCompanies = Array.isArray(values.companies) ? (values.companies as string[]) : []
571
- const nextCompanyIds = Array.from(
572
- new Set(
573
- rawCompanies
574
- .map((value) => (typeof value === 'string' ? value.trim() : ''))
575
- .filter((value) => value.length > 0 && isUuid(value)),
576
- ),
577
- )
578
- setSelectedCompanyIds(nextCompanyIds)
579
- if (nextCompanyIds.length) next.companies = nextCompanyIds
580
- else delete next.companies
581
-
582
- setFilterValues(next)
583
- setPage(1)
584
- }, [])
585
-
586
- const handleFiltersClear = React.useCallback(() => {
587
- setFilterValues({})
588
- setSelectedPersonIds([])
589
- setSelectedCompanyIds([])
590
- setPage(1)
591
- }, [])
592
-
593
311
  const queryParams = React.useMemo(() => {
594
312
  const params = new URLSearchParams()
595
313
  params.set('page', String(page))
@@ -601,35 +319,12 @@ export default function CustomersDealsPage() {
601
319
  if (search.trim().length) params.set('search', search.trim())
602
320
  if (selectedPersonIds.length) params.set('personId', selectedPersonIds.join(','))
603
321
  if (selectedCompanyIds.length) params.set('companyId', selectedCompanyIds.join(','))
604
- Object.entries(filterValues).forEach(([key, value]) => {
605
- if (key === 'people' || key === 'companies') return
606
- if (value == null) return
607
- if (Array.isArray(value)) {
608
- const normalized = value
609
- .map((item) => {
610
- if (item == null) return ''
611
- if (typeof item === 'string') return item.trim()
612
- return String(item).trim()
613
- })
614
- .filter((item) => item.length > 0)
615
- if (normalized.length) params.set(key, normalized.join(','))
616
- } else if (typeof value === 'object') {
617
- const obj = value as Record<string, unknown>
618
- const from = typeof obj.from === 'string' ? obj.from.trim() : ''
619
- const to = typeof obj.to === 'string' ? obj.to.trim() : ''
620
- if (from) params.set(`${key}[from]`, from)
621
- if (to) params.set(`${key}[to]`, to)
622
- } else {
623
- const stringValue = typeof value === 'string' ? value.trim() : String(value)
624
- if (stringValue) params.set(key, stringValue)
625
- }
626
- })
627
- const advancedParams = serializeAdvancedFilter(advancedFilterState)
322
+ const advancedParams = serializeTree(advancedFilterState)
628
323
  for (const [key, val] of Object.entries(advancedParams)) {
629
324
  params.set(key, val)
630
325
  }
631
326
  return params.toString()
632
- }, [advancedFilterState, filterValues, page, pageSize, search, selectedCompanyIds, selectedPersonIds, sorting])
327
+ }, [advancedFilterState, page, pageSize, search, selectedCompanyIds, selectedPersonIds, sorting])
633
328
 
634
329
  const currentParams = React.useMemo(
635
330
  () => Object.fromEntries(new URLSearchParams(queryParams)),
@@ -702,7 +397,7 @@ export default function CustomersDealsPage() {
702
397
  if (selectedPersonIds.length) selectedPersonIds.forEach((id) => params.append('personId', id))
703
398
  if (selectedCompanyIds.length) selectedCompanyIds.forEach((id) => params.append('companyId', id))
704
399
  if (page > 1) params.set('page', String(page))
705
- const advancedParams = serializeAdvancedFilter(advancedFilterState)
400
+ const advancedParams = serializeTree(advancedFilterState)
706
401
  for (const [key, val] of Object.entries(advancedParams)) {
707
402
  params.set(key, val)
708
403
  }
@@ -713,8 +408,6 @@ export default function CustomersDealsPage() {
713
408
  }, [pathname, router, page, search, selectedPersonIds, selectedCompanyIds, advancedFilterState])
714
409
 
715
410
  const handleRefresh = React.useCallback(() => {
716
- peopleCacheRef.current.clear()
717
- companiesCacheRef.current.clear()
718
411
  void Promise.all([
719
412
  invalidateCustomerDictionary(queryClient, 'deal-statuses'),
720
413
  invalidateCustomerDictionary(queryClient, 'pipeline-stages'),
@@ -864,35 +557,38 @@ export default function CustomersDealsPage() {
864
557
  return succeeded.length > 0
865
558
  }, [bulkMutationContextId, confirm, retryBulkMutation, runBulkMutation, t])
866
559
 
867
- const personOptions = peopleState.options
868
- const companyOptions = companiesState.options
869
-
870
- const peopleIdToLabel = peopleState.idToLabel
871
- const companyIdToLabel = companiesState.idToLabel
872
- const filters = React.useMemo<FilterDef[]>(() => [
873
- {
874
- id: 'people',
875
- label: t('customers.deals.list.filters.people'),
876
- type: 'tags',
877
- options: personOptions,
878
- loadOptions: loadPeopleOptions,
879
- placeholder: t('customers.deals.list.filters.peoplePlaceholder'),
880
- formatValue: (value: string) => peopleIdToLabel[value] ?? value,
881
- },
882
- {
883
- id: 'companies',
884
- label: t('customers.deals.list.filters.companies'),
885
- type: 'tags',
886
- options: companyOptions,
887
- loadOptions: loadCompanyOptions,
888
- placeholder: t('customers.deals.list.filters.companiesPlaceholder'),
889
- formatValue: (value: string) => companyIdToLabel[value] ?? value,
890
- },
891
- ], [companyIdToLabel, companyOptions, loadCompanyOptions, loadPeopleOptions, peopleIdToLabel, personOptions, t])
892
-
893
560
  const { data: customFieldDefs = [] } = useCustomFieldDefs([E.customers.customer_deal], {
894
561
  keyExtras: [scopeVersion, reloadToken],
895
562
  })
563
+ const currentUserId = useCurrentUserId()
564
+ const [ownerFilterOptions, setOwnerFilterOptions] = React.useState<AdvancedFilterOption[]>([])
565
+ React.useEffect(() => {
566
+ const controller = new AbortController()
567
+ let cancelled = false
568
+ void fetchAssignableStaffMembers('', { pageSize: 100, signal: controller.signal })
569
+ .then((items) => {
570
+ if (!cancelled) setOwnerFilterOptions(mapAssignableStaffToFilterOptions(items))
571
+ })
572
+ .catch(() => {
573
+ if (!cancelled) setOwnerFilterOptions([])
574
+ })
575
+ return () => {
576
+ cancelled = true
577
+ controller.abort()
578
+ }
579
+ }, [scopeVersion])
580
+ const resolvedOwnerFilterOptions = React.useMemo(
581
+ () => ensureCurrentUserFilterOption(
582
+ ownerFilterOptions,
583
+ currentUserId,
584
+ t('customers.filters.currentUser', 'Current user'),
585
+ ),
586
+ [currentUserId, ownerFilterOptions, t],
587
+ )
588
+ const loadOwnerFilterOptions = React.useCallback(async (query?: string): Promise<AdvancedFilterOption[]> => {
589
+ const items = await fetchAssignableStaffMembers(query ?? '', { pageSize: 100 })
590
+ return mapAssignableStaffToFilterOptions(items)
591
+ }, [])
896
592
 
897
593
  const columns = React.useMemo<ColumnDef<DealRow>[]>(() => {
898
594
  const noValue = <span className="text-muted-foreground text-sm">{t('customers.deals.list.noValue')}</span>
@@ -972,6 +668,7 @@ export default function CustomersDealsPage() {
972
668
  alwaysVisible: true,
973
669
  columnChooserGroup: 'Basic Info',
974
670
  filterKey: 'title',
671
+ filterGroup: 'Deal',
975
672
  maxWidth: '280px',
976
673
  },
977
674
  cell: ({ row }) => <span className="font-medium text-sm">{row.original.title}</span>,
@@ -979,19 +676,36 @@ export default function CustomersDealsPage() {
979
676
  {
980
677
  accessorKey: 'status',
981
678
  header: t('customers.deals.list.columns.status'),
982
- meta: { filterType: 'select' as const, columnChooserGroup: 'Basic Info', filterKey: 'status' },
679
+ meta: {
680
+ filterType: 'select' as const,
681
+ filterOptions: dictionaryOptions.dealStatuses,
682
+ columnChooserGroup: 'Basic Info',
683
+ filterKey: 'status',
684
+ filterGroup: 'Deal',
685
+ },
983
686
  cell: ({ row }) => renderDictionaryCell('deal-statuses', row.original.status),
984
687
  },
985
688
  {
986
689
  accessorKey: 'pipelineStage',
987
690
  header: t('customers.deals.list.columns.pipelineStage'),
988
- meta: { columnChooserGroup: 'Pipeline', filterKey: 'pipeline_stage' },
691
+ meta: {
692
+ filterType: 'select' as const,
693
+ filterOptions: dictionaryOptions.pipelineStages,
694
+ columnChooserGroup: 'Pipeline',
695
+ filterKey: 'pipeline_stage',
696
+ filterGroup: 'Deal',
697
+ },
989
698
  cell: ({ row }) => renderDictionaryCell('pipeline-stages', row.original.pipelineStage),
990
699
  },
991
700
  {
992
701
  accessorKey: 'pipelineId',
993
702
  header: t('customers.deals.list.columns.pipeline', 'Pipeline'),
994
- meta: { columnChooserGroup: 'Pipeline', filterKey: 'pipeline_id', maxWidth: '220px' },
703
+ meta: {
704
+ columnChooserGroup: 'Pipeline',
705
+ filterKey: 'pipeline_id',
706
+ filterGroup: 'Deal',
707
+ maxWidth: '220px',
708
+ },
995
709
  cell: ({ row }) => {
996
710
  const name = row.original.pipelineId ? pipelineNames[row.original.pipelineId] : null
997
711
  return name ? <span className="text-sm">{name}</span> : noValue
@@ -1000,7 +714,12 @@ export default function CustomersDealsPage() {
1000
714
  {
1001
715
  accessorKey: 'valueAmount',
1002
716
  header: t('customers.deals.list.columns.value'),
1003
- meta: { filterType: 'number' as const, columnChooserGroup: 'Financial', filterKey: 'value_amount' },
717
+ meta: {
718
+ filterType: 'number' as const,
719
+ columnChooserGroup: 'Financial',
720
+ filterKey: 'value_amount',
721
+ filterGroup: 'Deal',
722
+ },
1004
723
  cell: ({ row }) => (
1005
724
  <span className="text-sm font-medium">
1006
725
  {formatCurrency(row.original.valueAmount ?? null, row.original.valueCurrency ?? null, t('customers.deals.list.noValue'))}
@@ -1010,7 +729,12 @@ export default function CustomersDealsPage() {
1010
729
  {
1011
730
  accessorKey: 'probability',
1012
731
  header: t('customers.deals.list.columns.probability'),
1013
- meta: { filterType: 'number' as const, columnChooserGroup: 'Financial', filterKey: 'probability' },
732
+ meta: {
733
+ filterType: 'number' as const,
734
+ columnChooserGroup: 'Financial',
735
+ filterKey: 'probability',
736
+ filterGroup: 'Deal',
737
+ },
1014
738
  cell: ({ row }) => {
1015
739
  const value = row.original.probability
1016
740
  if (typeof value === 'number' && Number.isFinite(value)) {
@@ -1022,19 +746,41 @@ export default function CustomersDealsPage() {
1022
746
  {
1023
747
  accessorKey: 'expectedCloseAt',
1024
748
  header: t('customers.deals.list.columns.expectedClose'),
1025
- meta: { columnChooserGroup: 'Dates', filterKey: 'expected_close_at' },
749
+ meta: {
750
+ columnChooserGroup: 'Dates',
751
+ filterKey: 'expected_close_at',
752
+ filterGroup: 'Activity',
753
+ filterIconName: 'calendar',
754
+ },
1026
755
  cell: ({ row }) => (
1027
756
  <span className="text-sm">
1028
757
  {formatDateValue(row.original.expectedCloseAt ?? null, t('customers.deals.list.noValue'))}
1029
758
  </span>
1030
759
  ),
1031
760
  },
761
+ {
762
+ accessorKey: 'ownerUserId',
763
+ header: t('customers.deals.list.columns.owner', 'Owner'),
764
+ meta: {
765
+ columnChooserGroup: 'CRM',
766
+ filterType: 'select',
767
+ filterOptions: resolvedOwnerFilterOptions,
768
+ filterLoadOptions: loadOwnerFilterOptions,
769
+ filterGroup: 'CRM',
770
+ filterIconName: 'user-round',
771
+ filterKey: 'owner_user_id',
772
+ hidden: true,
773
+ },
774
+ cell: ({ row }) => row.original.ownerUserId ?? null,
775
+ },
1032
776
  {
1033
777
  accessorKey: 'companies',
1034
778
  header: t('customers.deals.list.columns.companies'),
1035
779
  meta: {
1036
780
  columnChooserGroup: 'Associations',
1037
781
  filterable: false,
782
+ filterGroup: 'CRM',
783
+ filterIconName: 'building-2',
1038
784
  maxWidth: '220px',
1039
785
  tooltipContent: (row: DealRow) =>
1040
786
  normalizeCollectionLabels(
@@ -1049,6 +795,8 @@ export default function CustomersDealsPage() {
1049
795
  meta: {
1050
796
  columnChooserGroup: 'Associations',
1051
797
  filterable: false,
798
+ filterGroup: 'CRM',
799
+ filterIconName: 'user-round',
1052
800
  maxWidth: '220px',
1053
801
  tooltipContent: (row: DealRow) =>
1054
802
  normalizeCollectionLabels(
@@ -1060,7 +808,12 @@ export default function CustomersDealsPage() {
1060
808
  {
1061
809
  accessorKey: 'updatedAt',
1062
810
  header: t('customers.deals.list.columns.updatedAt'),
1063
- meta: { columnChooserGroup: 'Dates', filterKey: 'updated_at' },
811
+ meta: {
812
+ columnChooserGroup: 'Dates',
813
+ filterKey: 'updated_at',
814
+ filterGroup: 'Activity',
815
+ filterIconName: 'calendar',
816
+ },
1064
817
  cell: ({ row }) => (
1065
818
  <span className="text-sm">
1066
819
  {formatDateValue(row.original.updatedAt ?? null, t('customers.deals.list.noValue'))}
@@ -1069,7 +822,75 @@ export default function CustomersDealsPage() {
1069
822
  },
1070
823
  ...customColumns,
1071
824
  ]
1072
- }, [customFieldDefs, dictionaryMaps, pipelineNames, t])
825
+ }, [customFieldDefs, dictionaryMaps, dictionaryOptions, loadOwnerFilterOptions, pipelineNames, resolvedOwnerFilterOptions, t])
826
+
827
+ const { advancedFilterFields } = useAutoDiscoveredFields({ columns, customFieldDefs })
828
+
829
+ // Sync auto-discovered fields into the `filterPanel` declared at the top of
830
+ // the component. See the comment on the `panelFields` state for why this
831
+ // late-binding is safe. Bail out by content (field-key list) — every render
832
+ // of `useAutoDiscoveredFields` produces fresh `FilterFieldDef` object refs
833
+ // even when the set of fields hasn't actually changed, so a naive reference
834
+ // setState would loop ("Maximum update depth exceeded").
835
+ React.useEffect(() => {
836
+ setPanelFields((prev) => {
837
+ if (prev === advancedFilterFields) return prev
838
+ if (prev.length === advancedFilterFields.length) {
839
+ let same = true
840
+ for (let i = 0; i < prev.length; i++) {
841
+ if (prev[i].key !== advancedFilterFields[i].key) { same = false; break }
842
+ }
843
+ if (same) return prev
844
+ }
845
+ return advancedFilterFields
846
+ })
847
+ }, [advancedFilterFields])
848
+
849
+ const associationFilterFields = React.useMemo<FilterFieldDef[]>(() => {
850
+ const personLabels = new Map<string, string>()
851
+ const companyLabels = new Map<string, string>()
852
+ for (const row of rows) {
853
+ for (const person of row.people) {
854
+ if (selectedPersonIds.includes(person.id) && person.label.trim().length > 0) {
855
+ personLabels.set(person.id, person.label)
856
+ }
857
+ }
858
+ for (const company of row.companies) {
859
+ if (selectedCompanyIds.includes(company.id) && company.label.trim().length > 0) {
860
+ companyLabels.set(company.id, company.label)
861
+ }
862
+ }
863
+ }
864
+ return [
865
+ {
866
+ key: 'people',
867
+ label: t('customers.deals.list.columns.people', 'People'),
868
+ type: 'select',
869
+ options: selectedPersonIds.map((id) => ({ value: id, label: personLabels.get(id) ?? id })),
870
+ },
871
+ {
872
+ key: 'companies',
873
+ label: t('customers.deals.list.columns.companies', 'Companies'),
874
+ type: 'select',
875
+ options: selectedCompanyIds.map((id) => ({ value: id, label: companyLabels.get(id) ?? id })),
876
+ },
877
+ ]
878
+ }, [rows, selectedCompanyIds, selectedPersonIds, t])
879
+ const associationFilterTree = React.useMemo<AdvancedFilterTree>(() => {
880
+ const rules: Array<{ field: string; operator: 'is_any_of'; value: string[] }> = []
881
+ if (selectedPersonIds.length) rules.push({ field: 'people', operator: 'is_any_of', value: selectedPersonIds })
882
+ if (selectedCompanyIds.length) rules.push({ field: 'companies', operator: 'is_any_of', value: selectedCompanyIds })
883
+ return rules.length ? makeMultiRuleTree(rules) : createEmptyTree()
884
+ }, [selectedCompanyIds, selectedPersonIds])
885
+ const handleAssociationFilterRemove = React.useCallback((nodeId: string) => {
886
+ const node = associationFilterTree.root.children.find((child) => child.id === nodeId)
887
+ if (!node || node.type !== 'rule') return
888
+ if (node.field === 'people') setSelectedPersonIds([])
889
+ if (node.field === 'companies') setSelectedCompanyIds([])
890
+ setPage(1)
891
+ }, [associationFilterTree.root.children])
892
+
893
+ const dealsPresets = React.useMemo<FilterPreset[]>(() => makeDealsPresets(), [])
1073
894
 
1074
895
  return (
1075
896
  <Page>
@@ -1136,10 +957,6 @@ export default function CustomersDealsPage() {
1136
957
  searchValue={search}
1137
958
  onSearchChange={handleSearchChange}
1138
959
  searchPlaceholder={t('customers.deals.list.searchPlaceholder')}
1139
- filters={filters}
1140
- filterValues={filterValues}
1141
- onFiltersApply={handleFiltersApply}
1142
- onFiltersClear={handleFiltersClear}
1143
960
  pagination={{
1144
961
  page,
1145
962
  pageSize,
@@ -1159,14 +976,61 @@ export default function CustomersDealsPage() {
1159
976
  entityId={E.customers.customer_deal}
1160
977
  perspective={{ tableId: 'customers.deals.list' }}
1161
978
  advancedFilter={{
1162
- auto: true,
1163
- value: advancedFilterState,
1164
- onChange: setAdvancedFilterState,
1165
- onApply: () => { setPage(1) },
1166
- onClear: () => { setAdvancedFilterState({ logic: 'and', conditions: [] }); setPage(1) },
1167
- }}
979
+ auto: true,
980
+ value: filterPanel.tree,
981
+ onChange: filterPanel.setTree,
982
+ onApply: () => filterPanel.flush(),
983
+ onClear: handleAdvancedFilterClear,
984
+ triggerRef: filtersTriggerRef,
985
+ externalPopover: true,
986
+ onTriggerClick: () => setFiltersOpen((prev) => !prev),
987
+ onApplyTree: (tree) => {
988
+ filterPanel.replaceTree(tree)
989
+ setPage(1)
990
+ },
991
+ }}
992
+ activeFilterChips={(
993
+ <>
994
+ <ActiveFilterChips
995
+ tree={associationFilterTree}
996
+ fields={associationFilterFields}
997
+ popoverOpen={filtersOpen}
998
+ onRemoveNode={handleAssociationFilterRemove}
999
+ onOpen={() => setFiltersOpen(true)}
1000
+ />
1001
+ <ActiveFilterChips
1002
+ tree={filterPanel.tree}
1003
+ fields={advancedFilterFields}
1004
+ popoverOpen={filtersOpen}
1005
+ onRemoveNode={(id) => filterPanel.dispatch({ type: 'removeNode', nodeId: id })}
1006
+ onOpen={() => setFiltersOpen(true)}
1007
+ />
1008
+ </>
1009
+ )}
1010
+ filterAwareEmptyState={{
1011
+ active: advancedFilterState.root.children.length > 0,
1012
+ entityNamePlural: t('customers.deals.entityPlural', 'deals'),
1013
+ canRemoveLast: filterPanel.tree.root.children.length > 0,
1014
+ onClearAll: handleAdvancedFilterClear,
1015
+ onRemoveLast: () => filterPanel.dispatch({ type: 'removeLast' }),
1016
+ }}
1168
1017
  virtualized
1169
1018
  />
1019
+ <AdvancedFilterPanel
1020
+ fields={advancedFilterFields}
1021
+ value={filterPanel.tree}
1022
+ onChange={filterPanel.setTree}
1023
+ onApply={filterPanel.flush}
1024
+ onClear={handleAdvancedFilterClear}
1025
+ onFlush={filterPanel.flush}
1026
+ pendingErrors={filterPanel.pendingErrors}
1027
+ userId={currentUserId}
1028
+ presets={dealsPresets}
1029
+ open={filtersOpen}
1030
+ onOpenChange={setFiltersOpen}
1031
+ triggerRef={filtersTriggerRef}
1032
+ savedFilterStorageKey="customers.deals.list"
1033
+ />
1170
1034
  </PageBody>
1171
1035
  {ConfirmDialogElement}
1172
1036
  </Page>
@@ -1201,6 +1065,7 @@ function mapDeal(item: Record<string, unknown>): DealRow | null {
1201
1065
  : null
1202
1066
  const expectedCloseAt = typeof item.expected_close_at === 'string' ? item.expected_close_at : null
1203
1067
  const updatedAt = typeof item.updated_at === 'string' ? item.updated_at : null
1068
+ const ownerUserId = typeof item.owner_user_id === 'string' ? item.owner_user_id : null
1204
1069
  const peopleRaw = Array.isArray(item.people) ? item.people : []
1205
1070
  const companiesRaw = Array.isArray(item.companies) ? item.companies : []
1206
1071
  const people = peopleRaw
@@ -1239,6 +1104,7 @@ function mapDeal(item: Record<string, unknown>): DealRow | null {
1239
1104
  probability,
1240
1105
  expectedCloseAt,
1241
1106
  updatedAt,
1107
+ ownerUserId,
1242
1108
  people,
1243
1109
  companies,
1244
1110
  ...customFields,