@open-mercato/core 0.4.11-develop.1355.50152f3ee9 → 0.4.11-develop.1362.574a071900

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 (35) hide show
  1. package/dist/modules/customers/api/companies/route.js +141 -3
  2. package/dist/modules/customers/api/companies/route.js.map +2 -2
  3. package/dist/modules/customers/api/deals/route.js +52 -3
  4. package/dist/modules/customers/api/deals/route.js.map +2 -2
  5. package/dist/modules/customers/api/people/route.js +145 -3
  6. package/dist/modules/customers/api/people/route.js.map +2 -2
  7. package/dist/modules/customers/api/utils.js +195 -0
  8. package/dist/modules/customers/api/utils.js.map +2 -2
  9. package/dist/modules/customers/backend/customers/companies/page.js +171 -6
  10. package/dist/modules/customers/backend/customers/companies/page.js.map +2 -2
  11. package/dist/modules/customers/backend/customers/deals/page.js +100 -7
  12. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  13. package/dist/modules/customers/backend/customers/people/page.js +180 -7
  14. package/dist/modules/customers/backend/customers/people/page.js.map +2 -2
  15. package/dist/modules/customers/commands/interactions.js +7 -0
  16. package/dist/modules/customers/commands/interactions.js.map +2 -2
  17. package/dist/modules/customers/components/detail/DealForm.js +1 -0
  18. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  19. package/dist/modules/query_index/lib/engine.js +81 -1
  20. package/dist/modules/query_index/lib/engine.js.map +2 -2
  21. package/package.json +3 -3
  22. package/src/modules/customers/api/companies/route.ts +151 -3
  23. package/src/modules/customers/api/deals/route.ts +54 -3
  24. package/src/modules/customers/api/people/route.ts +160 -3
  25. package/src/modules/customers/api/utils.ts +286 -0
  26. package/src/modules/customers/backend/customers/companies/page.tsx +184 -9
  27. package/src/modules/customers/backend/customers/deals/page.tsx +127 -35
  28. package/src/modules/customers/backend/customers/people/page.tsx +191 -10
  29. package/src/modules/customers/commands/interactions.ts +7 -0
  30. package/src/modules/customers/components/detail/DealForm.tsx +1 -0
  31. package/src/modules/customers/i18n/de.json +12 -0
  32. package/src/modules/customers/i18n/en.json +15 -3
  33. package/src/modules/customers/i18n/es.json +12 -0
  34. package/src/modules/customers/i18n/pl.json +12 -0
  35. package/src/modules/query_index/lib/engine.ts +95 -1
@@ -1,4 +1,12 @@
1
1
  import { createScopedApiHelpers } from '@open-mercato/shared/lib/api/scoped'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'
4
+ import type { EntityId } from '@open-mercato/shared/modules/entities'
5
+ import type { QueryCustomFieldSource, QueryJoinEdge, QueryEngine } from '@open-mercato/shared/lib/query/types'
6
+ import { resolveSearchConfig } from '@open-mercato/shared/lib/search/config'
7
+ import { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'
8
+ import { deserializeAdvancedFilter } from '@open-mercato/shared/lib/query/advanced-filter'
9
+ import { SortDir } from '@open-mercato/shared/lib/query/types'
2
10
 
3
11
  const { withScopedPayload, parseScopedCommandInput } = createScopedApiHelpers({
4
12
  messages: {
@@ -7,4 +15,282 @@ const { withScopedPayload, parseScopedCommandInput } = createScopedApiHelpers({
7
15
  },
8
16
  })
9
17
 
18
+ const NO_MATCH_ID = '00000000-0000-0000-0000-000000000000'
19
+
20
+ type SearchTokenMatchInput = {
21
+ ctx: CrudCtx
22
+ entityType: string
23
+ fields: string[]
24
+ query: string
25
+ }
26
+
27
+ type SearchTokenSource = {
28
+ entityType: string
29
+ fields: string[]
30
+ mapToEntityIds?: {
31
+ table: string
32
+ sourceColumn?: string
33
+ targetColumn: string
34
+ tenantColumn?: string
35
+ organizationColumn?: string
36
+ }
37
+ }
38
+
39
+ async function enrichSearchSourcesWithCustomFieldTokens(
40
+ ctx: CrudCtx,
41
+ sources: SearchTokenSource[],
42
+ ): Promise<SearchTokenSource[]> {
43
+ const entityTypes = Array.from(
44
+ new Set(
45
+ sources
46
+ .map((source) => source.entityType)
47
+ .filter((value): value is string => typeof value === 'string' && value.length > 0),
48
+ ),
49
+ )
50
+ if (!entityTypes.length) return sources
51
+
52
+ const em = ctx.container.resolve('em') as EntityManager
53
+ const knex = (em as any).getConnection().getKnex()
54
+ let defsQuery = knex('custom_field_defs')
55
+ .select('entity_id', 'key', 'kind')
56
+ .whereIn('entity_id', entityTypes)
57
+ .andWhere('is_active', true)
58
+
59
+ defsQuery = defsQuery.andWhere((builder: any) => {
60
+ builder.where({ tenant_id: ctx.auth?.tenantId ?? null }).orWhereNull('tenant_id')
61
+ })
62
+
63
+ if (ctx.selectedOrganizationId) {
64
+ defsQuery = defsQuery.andWhere((builder: any) => {
65
+ builder.where({ organization_id: ctx.selectedOrganizationId }).orWhereNull('organization_id')
66
+ })
67
+ } else if (Array.isArray(ctx.organizationIds) && ctx.organizationIds.length > 0) {
68
+ defsQuery = defsQuery.andWhere((builder: any) => {
69
+ builder.whereIn('organization_id', ctx.organizationIds).orWhereNull('organization_id')
70
+ })
71
+ }
72
+
73
+ const customFieldKeysByEntity = new Map<string, Set<string>>()
74
+ const rows = await defsQuery
75
+ for (const row of rows as Array<{ entity_id?: unknown; key?: unknown; kind?: unknown }>) {
76
+ if (row.kind === 'attachment') continue
77
+ const entityType = typeof row.entity_id === 'string' ? row.entity_id : null
78
+ const key = typeof row.key === 'string' ? row.key.trim() : ''
79
+ if (!entityType || !key) continue
80
+ const bucket = customFieldKeysByEntity.get(entityType) ?? new Set<string>()
81
+ bucket.add(`cf:${key}`)
82
+ customFieldKeysByEntity.set(entityType, bucket)
83
+ }
84
+
85
+ return sources.map((source) => {
86
+ const customFieldKeys = customFieldKeysByEntity.get(source.entityType)
87
+ return {
88
+ ...source,
89
+ fields: Array.from(new Set([
90
+ 'search_text',
91
+ ...source.fields,
92
+ ...(customFieldKeys ? Array.from(customFieldKeys) : []),
93
+ ])),
94
+ }
95
+ })
96
+ }
97
+
98
+ async function findSearchTokenEntityIds({
99
+ ctx,
100
+ entityType,
101
+ fields,
102
+ query,
103
+ }: SearchTokenMatchInput): Promise<string[] | null> {
104
+ const trimmed = query.trim()
105
+ if (!trimmed) return null
106
+
107
+ const tokens = tokenizeText(trimmed, resolveSearchConfig())
108
+ if (!tokens.hashes.length) return []
109
+
110
+ const em = ctx.container.resolve('em') as EntityManager
111
+ const knex = (em as any).getConnection().getKnex()
112
+ let searchQuery = knex('search_tokens')
113
+ .select('entity_id')
114
+ .where('entity_type', entityType)
115
+ .whereIn('field', fields)
116
+ .whereIn('token_hash', tokens.hashes)
117
+ .groupBy('entity_id')
118
+ .havingRaw('count(distinct token_hash) >= ?', [tokens.hashes.length])
119
+
120
+ if (ctx.auth?.tenantId !== undefined) {
121
+ searchQuery = searchQuery.whereRaw('tenant_id is not distinct from ?', [ctx.auth?.tenantId ?? null])
122
+ }
123
+ if (ctx.selectedOrganizationId) {
124
+ searchQuery = searchQuery.where('organization_id', ctx.selectedOrganizationId)
125
+ } else if (Array.isArray(ctx.organizationIds) && ctx.organizationIds.length > 0) {
126
+ searchQuery = searchQuery.whereIn('organization_id', ctx.organizationIds)
127
+ }
128
+
129
+ const rows = await searchQuery
130
+ return rows
131
+ .map((row: { entity_id?: unknown }) => (typeof row.entity_id === 'string' ? row.entity_id : null))
132
+ .filter((id: string | null): id is string => typeof id === 'string' && id.length > 0)
133
+ }
134
+
135
+ async function mapScopedEntityIds({
136
+ ctx,
137
+ ids,
138
+ config,
139
+ }: {
140
+ ctx: CrudCtx
141
+ ids: string[]
142
+ config: NonNullable<SearchTokenSource['mapToEntityIds']>
143
+ }): Promise<string[]> {
144
+ if (!ids.length) return []
145
+
146
+ const em = ctx.container.resolve('em') as EntityManager
147
+ const knex = (em as any).getConnection().getKnex()
148
+ const sourceColumn = config.sourceColumn ?? 'id'
149
+ const tenantColumn = config.tenantColumn ?? 'tenant_id'
150
+ const organizationColumn = config.organizationColumn ?? 'organization_id'
151
+
152
+ let mapQuery = knex(config.table)
153
+ .select(config.targetColumn)
154
+ .whereIn(sourceColumn, ids)
155
+
156
+ if (ctx.auth?.tenantId !== undefined) {
157
+ mapQuery = mapQuery.whereRaw('?? is not distinct from ?', [tenantColumn, ctx.auth?.tenantId ?? null])
158
+ }
159
+ if (ctx.selectedOrganizationId) {
160
+ mapQuery = mapQuery.where(organizationColumn, ctx.selectedOrganizationId)
161
+ } else if (Array.isArray(ctx.organizationIds) && ctx.organizationIds.length > 0) {
162
+ mapQuery = mapQuery.whereIn(organizationColumn, ctx.organizationIds)
163
+ }
164
+
165
+ const rows = await mapQuery
166
+ return rows
167
+ .map((row: Record<string, unknown>) => {
168
+ const value = row[config.targetColumn]
169
+ return typeof value === 'string' ? value : null
170
+ })
171
+ .filter((id: string | null): id is string => typeof id === 'string' && id.length > 0)
172
+ }
173
+
174
+ export async function findMatchingEntityIdsBySearchTokensAcrossSources({
175
+ ctx,
176
+ sources,
177
+ query,
178
+ }: {
179
+ ctx: CrudCtx
180
+ sources: SearchTokenSource[]
181
+ query: string
182
+ }): Promise<string[] | null> {
183
+ const trimmed = query.trim()
184
+ if (!trimmed) return null
185
+
186
+ const enrichedSources = await enrichSearchSourcesWithCustomFieldTokens(ctx, sources)
187
+ const matchedIds = new Set<string>()
188
+ for (const source of enrichedSources) {
189
+ const rawIds = await findSearchTokenEntityIds({
190
+ ctx,
191
+ entityType: source.entityType,
192
+ fields: source.fields,
193
+ query: trimmed,
194
+ })
195
+ if (rawIds === null) return null
196
+ const entityIds = source.mapToEntityIds
197
+ ? await mapScopedEntityIds({ ctx, ids: rawIds, config: source.mapToEntityIds })
198
+ : rawIds
199
+ entityIds.forEach((id) => matchedIds.add(id))
200
+ }
201
+
202
+ return Array.from(matchedIds)
203
+ }
204
+
205
+ export async function findMatchingEntityIdsBySearchTokens({
206
+ ctx,
207
+ entityType,
208
+ fields,
209
+ query,
210
+ }: SearchTokenMatchInput): Promise<string[] | null> {
211
+ return findMatchingEntityIdsBySearchTokensAcrossSources({
212
+ ctx,
213
+ query,
214
+ sources: [{ entityType, fields }],
215
+ })
216
+ }
217
+
218
+ export function applyEntityIdRestriction(
219
+ filters: Record<string, unknown>,
220
+ ids: string[] | null,
221
+ ): void {
222
+ if (ids === null) return
223
+ const currentIdFilter =
224
+ filters.id && typeof filters.id === 'object' && !Array.isArray(filters.id)
225
+ ? (filters.id as { $eq?: unknown; $in?: unknown })
226
+ : null
227
+ const currentEq = typeof currentIdFilter?.$eq === 'string' ? currentIdFilter.$eq : null
228
+
229
+ if (currentEq) {
230
+ filters.id = ids.includes(currentEq) ? { $eq: currentEq } : { $eq: NO_MATCH_ID }
231
+ return
232
+ }
233
+
234
+ filters.id = ids.length > 0 ? { $in: ids } : { $eq: NO_MATCH_ID }
235
+ }
236
+
237
+ export function consumeAdvancedFilterState(query: Record<string, unknown>) {
238
+ const state = deserializeAdvancedFilter(query)
239
+ if (!state) return null
240
+
241
+ for (const key of Object.keys(query)) {
242
+ if (key.startsWith('filter[')) {
243
+ delete query[key]
244
+ }
245
+ }
246
+
247
+ return state
248
+ }
249
+
250
+ export async function findMatchingEntityIdsWithQueryEngine({
251
+ ctx,
252
+ entityId,
253
+ filters,
254
+ customFieldSources,
255
+ joins,
256
+ }: {
257
+ ctx: CrudCtx
258
+ entityId: EntityId
259
+ filters: Record<string, unknown>
260
+ customFieldSources?: QueryCustomFieldSource[]
261
+ joins?: QueryJoinEdge[]
262
+ }): Promise<string[]> {
263
+ const qe = ctx.container.resolve('queryEngine') as QueryEngine
264
+ const ids = new Set<string>()
265
+ const pageSize = 100
266
+ let page = 1
267
+ let total = 0
268
+
269
+ do {
270
+ const result = await qe.query(entityId, {
271
+ fields: ['id'],
272
+ filters,
273
+ page: { page, pageSize },
274
+ sort: [{ field: 'id', dir: SortDir.Asc }],
275
+ tenantId: ctx.auth?.tenantId ?? undefined,
276
+ organizationId: ctx.selectedOrganizationId ?? undefined,
277
+ organizationIds: ctx.organizationIds ?? undefined,
278
+ customFieldSources,
279
+ joins,
280
+ })
281
+
282
+ total = result.total ?? 0
283
+ for (const item of result.items ?? []) {
284
+ const id = item && typeof item === 'object' ? (item as Record<string, unknown>).id : null
285
+ if (typeof id === 'string' && id.length > 0) {
286
+ ids.add(id)
287
+ }
288
+ }
289
+ if (!result.items?.length) break
290
+ page += 1
291
+ } while (ids.size < total)
292
+
293
+ return Array.from(ids)
294
+ }
295
+
10
296
  export { withScopedPayload, parseScopedCommandInput }
@@ -17,6 +17,8 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
17
17
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
18
18
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
19
19
  import type { FilterOption } from '@open-mercato/ui/backend/FilterOverlay'
20
+ import type { AdvancedFilterState } from '@open-mercato/shared/lib/query/advanced-filter'
21
+ import { serializeAdvancedFilter } from '@open-mercato/shared/lib/query/advanced-filter'
20
22
  import {
21
23
  DictionaryValue,
22
24
  renderDictionaryColor,
@@ -26,8 +28,12 @@ import {
26
28
  } from '../../../lib/dictionaries'
27
29
  import {
28
30
  useCustomFieldDefs,
29
- filterCustomFieldDefs,
30
31
  } from '@open-mercato/ui/backend/utils/customFieldDefs'
32
+ import {
33
+ mapCustomFieldKindToFilterType,
34
+ normalizeCustomFieldFilterOptions,
35
+ supportsCustomFieldColumn,
36
+ } from '@open-mercato/ui/backend/utils/customFieldColumns'
31
37
  import { useQueryClient } from '@tanstack/react-query'
32
38
  import { ensureCustomerDictionary } from '../../../components/detail/hooks/useCustomerDictionary'
33
39
 
@@ -37,6 +43,13 @@ type CompanyRow = {
37
43
  description?: string | null
38
44
  email?: string | null
39
45
  phone?: string | null
46
+ legalName?: string | null
47
+ brandName?: string | null
48
+ domain?: string | null
49
+ websiteUrl?: string | null
50
+ industry?: string | null
51
+ sizeBucket?: string | null
52
+ annualRevenue?: string | null
40
53
  status?: string | null
41
54
  lifecycleStage?: string | null
42
55
  nextInteractionAt?: string | null
@@ -71,6 +84,18 @@ function mapApiItem(item: Record<string, unknown>): CompanyRow | null {
71
84
  const description = typeof item.description === 'string' ? item.description : null
72
85
  const email = typeof item.primary_email === 'string' ? item.primary_email : null
73
86
  const phone = typeof item.primary_phone === 'string' ? item.primary_phone : null
87
+ const legalName = typeof item.legal_name === 'string' ? item.legal_name : null
88
+ const brandName = typeof item.brand_name === 'string' ? item.brand_name : null
89
+ const domain = typeof item.domain === 'string' ? item.domain : null
90
+ const websiteUrl = typeof item.website_url === 'string' ? item.website_url : null
91
+ const industry = typeof item.industry === 'string' ? item.industry : null
92
+ const sizeBucket = typeof item.size_bucket === 'string' ? item.size_bucket : null
93
+ const annualRevenue =
94
+ typeof item.annual_revenue === 'string'
95
+ ? item.annual_revenue
96
+ : typeof item.annual_revenue === 'number'
97
+ ? String(item.annual_revenue)
98
+ : null
74
99
  const status = typeof item.status === 'string' ? item.status : null
75
100
  const lifecycleStage = typeof item.lifecycle_stage === 'string' ? item.lifecycle_stage : null
76
101
  const nextInteractionAt = typeof item.next_interaction_at === 'string' ? item.next_interaction_at : null
@@ -91,6 +116,13 @@ function mapApiItem(item: Record<string, unknown>): CompanyRow | null {
91
116
  description,
92
117
  email,
93
118
  phone,
119
+ legalName,
120
+ brandName,
121
+ domain,
122
+ websiteUrl,
123
+ industry,
124
+ sizeBucket,
125
+ annualRevenue,
94
126
  status,
95
127
  lifecycleStage,
96
128
  nextInteractionAt,
@@ -107,11 +139,13 @@ export default function CustomersCompaniesPage() {
107
139
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
108
140
  const [rows, setRows] = React.useState<CompanyRow[]>([])
109
141
  const [page, setPage] = React.useState(1)
110
- const [pageSize] = React.useState(20)
142
+ const [pageSize, setPageSize] = React.useState(20)
143
+ const [sorting, setSorting] = React.useState<import('@tanstack/react-table').SortingState>([])
111
144
  const [total, setTotal] = React.useState(0)
112
145
  const [totalPages, setTotalPages] = React.useState(1)
113
146
  const [search, setSearch] = React.useState('')
114
147
  const [filterValues, setFilterValues] = React.useState<FilterValues>({})
148
+ const [advancedFilterState, setAdvancedFilterState] = React.useState<AdvancedFilterState>({ logic: 'and', conditions: [] })
115
149
  const [isLoading, setIsLoading] = React.useState(true)
116
150
  const [reloadToken, setReloadToken] = React.useState(0)
117
151
  const [cacheStatus, setCacheStatus] = React.useState<'hit' | 'miss' | null>(null)
@@ -131,6 +165,10 @@ export default function CustomersCompaniesPage() {
131
165
  const queryClient = useQueryClient()
132
166
  const t = useT()
133
167
  const router = useRouter()
168
+ const handlePageSizeChange = React.useCallback((newSize: number) => {
169
+ setPageSize(newSize)
170
+ setPage(1)
171
+ }, [])
134
172
  const fetchDictionaryEntries = React.useCallback(async (kind: DictionaryKindKey) => {
135
173
  try {
136
174
  const data = await ensureCustomerDictionary(queryClient, kind, scopeVersion)
@@ -299,6 +337,10 @@ export default function CustomersCompaniesPage() {
299
337
  const params = new URLSearchParams()
300
338
  params.set('page', String(page))
301
339
  params.set('pageSize', String(pageSize))
340
+ if (sorting.length > 0) {
341
+ params.set('sort', sorting[0].id)
342
+ params.set('order', sorting[0].desc ? 'desc' : 'asc')
343
+ }
302
344
  if (search.trim()) params.set('search', search.trim())
303
345
  const status = filterValues.status
304
346
  if (typeof status === 'string' && status.trim()) params.set('status', status)
@@ -358,8 +400,12 @@ export default function CustomersCompaniesPage() {
358
400
  if (stringValue) params.set(key, stringValue)
359
401
  }
360
402
  })
403
+ const advancedParams = serializeAdvancedFilter(advancedFilterState)
404
+ for (const [key, val] of Object.entries(advancedParams)) {
405
+ params.set(key, val)
406
+ }
361
407
  return params.toString()
362
- }, [filterValues, page, pageSize, search, tagIdToLabel, tagLabelToId])
408
+ }, [advancedFilterState, filterValues, page, pageSize, search, sorting, tagIdToLabel, tagLabelToId])
363
409
 
364
410
  const currentParams = React.useMemo(() => Object.fromEntries(new URLSearchParams(queryParams)), [queryParams])
365
411
  const exportConfig = React.useMemo(() => ({
@@ -439,6 +485,35 @@ export default function CustomersCompaniesPage() {
439
485
  }
440
486
  }, [confirm, handleRefresh, t])
441
487
 
488
+ const handleBulkDelete = React.useCallback(async (selectedRows: CompanyRow[]) => {
489
+ const confirmed = await confirm({
490
+ title: t('customers.companies.list.bulkDelete.title', 'Delete {count} companies?', { count: selectedRows.length }),
491
+ description: t('customers.companies.list.bulkDelete.description', 'This action cannot be undone.'),
492
+ variant: 'destructive',
493
+ })
494
+ if (!confirmed) return false
495
+ let deletedCount = 0
496
+ for (const row of selectedRows) {
497
+ try {
498
+ await apiCallOrThrow(`/api/customers/companies?id=${encodeURIComponent(row.id)}`, {
499
+ method: 'DELETE',
500
+ headers: { 'content-type': 'application/json' },
501
+ })
502
+ deletedCount++
503
+ } catch {}
504
+ }
505
+ if (deletedCount > 0) {
506
+ setRows((prev) => {
507
+ const deletedIds = new Set(selectedRows.map((r) => r.id))
508
+ return prev.filter((r) => !deletedIds.has(r.id))
509
+ })
510
+ setTotal((prev) => Math.max(0, prev - deletedCount))
511
+ flash(t('customers.companies.list.bulkDelete.success', '{count} companies deleted', { count: deletedCount }), 'success')
512
+ setReloadToken((prev) => prev + 1)
513
+ }
514
+ return deletedCount > 0
515
+ }, [confirm, t])
516
+
442
517
  const handleFiltersApply = React.useCallback((values: FilterValues) => {
443
518
  const next: FilterValues = {}
444
519
  Object.entries(values).forEach(([key, value]) => {
@@ -509,6 +584,7 @@ export default function CustomersCompaniesPage() {
509
584
  {
510
585
  accessorKey: 'name',
511
586
  header: t('customers.companies.list.columns.name'),
587
+ meta: { alwaysVisible: true, columnChooserGroup: 'Basic Info', filterKey: 'display_name' },
512
588
  cell: ({ row }) => (
513
589
  <Link href={`/backend/customers/companies-v2/${row.original.id}`} className="font-medium hover:underline">
514
590
  {row.original.name}
@@ -518,21 +594,36 @@ export default function CustomersCompaniesPage() {
518
594
  {
519
595
  accessorKey: 'email',
520
596
  header: t('customers.companies.list.columns.email'),
597
+ meta: { columnChooserGroup: 'Contact', filterKey: 'primary_email' },
521
598
  cell: ({ row }) => row.original.email || noValue,
522
599
  },
600
+ {
601
+ accessorKey: 'phone',
602
+ header: t('customers.companies.detail.highlights.primaryPhone', 'Primary phone'),
603
+ meta: { columnChooserGroup: 'Contact', hidden: true, filterKey: 'primary_phone' },
604
+ cell: ({ row }) => row.original.phone || noValue,
605
+ },
523
606
  {
524
607
  accessorKey: 'status',
525
608
  header: t('customers.companies.list.columns.status'),
609
+ meta: { filterType: 'select' as const, filterOptions: dictionaryOptions.statuses, columnChooserGroup: 'Basic Info' },
526
610
  cell: ({ row }) => renderDictionaryCell('statuses', row.original.status),
527
611
  },
528
612
  {
529
613
  accessorKey: 'lifecycleStage',
530
614
  header: t('customers.companies.list.columns.lifecycleStage'),
615
+ meta: {
616
+ filterType: 'select' as const,
617
+ filterOptions: dictionaryOptions.lifecycleStages,
618
+ columnChooserGroup: 'Basic Info',
619
+ filterKey: 'lifecycle_stage',
620
+ },
531
621
  cell: ({ row }) => renderDictionaryCell('lifecycle-stages', row.original.lifecycleStage),
532
622
  },
533
623
  {
534
624
  accessorKey: 'nextInteractionAt',
535
625
  header: t('customers.companies.list.columns.nextInteraction'),
626
+ meta: { columnChooserGroup: 'Dates', filterKey: 'next_interaction_at' },
536
627
  cell: ({ row }) =>
537
628
  row.original.nextInteractionAt
538
629
  ? (
@@ -560,15 +651,78 @@ export default function CustomersCompaniesPage() {
560
651
  {
561
652
  accessorKey: 'source',
562
653
  header: t('customers.companies.list.columns.source'),
654
+ meta: { filterType: 'select' as const, filterOptions: dictionaryOptions.sources, columnChooserGroup: 'Basic Info' },
563
655
  cell: ({ row }) => renderDictionaryCell('sources', row.original.source),
564
656
  },
657
+ {
658
+ accessorKey: 'legalName',
659
+ header: t('customers.companies.detail.fields.legalName', 'Legal name'),
660
+ meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'company_profile.legal_name' },
661
+ cell: ({ row }) => row.original.legalName || noValue,
662
+ },
663
+ {
664
+ accessorKey: 'brandName',
665
+ header: t('customers.companies.detail.fields.brandName', 'Brand name'),
666
+ meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'company_profile.brand_name' },
667
+ cell: ({ row }) => row.original.brandName || noValue,
668
+ },
669
+ {
670
+ accessorKey: 'domain',
671
+ header: t('customers.companies.detail.fields.domain', 'Domain'),
672
+ meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'company_profile.domain' },
673
+ cell: ({ row }) => row.original.domain || noValue,
674
+ },
675
+ {
676
+ accessorKey: 'websiteUrl',
677
+ header: t('customers.companies.detail.fields.website', 'Website'),
678
+ meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'company_profile.website_url' },
679
+ cell: ({ row }) => row.original.websiteUrl || noValue,
680
+ },
681
+ {
682
+ accessorKey: 'industry',
683
+ header: t('customers.companies.detail.fields.industry', 'Industry'),
684
+ meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'company_profile.industry' },
685
+ cell: ({ row }) => row.original.industry || noValue,
686
+ },
687
+ {
688
+ accessorKey: 'sizeBucket',
689
+ header: t('customers.companies.detail.fields.sizeBucket', 'Company size'),
690
+ meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'company_profile.size_bucket' },
691
+ cell: ({ row }) => row.original.sizeBucket || noValue,
692
+ },
693
+ {
694
+ accessorKey: 'annualRevenue',
695
+ header: t('customers.companies.detail.highlights.annualRevenue', 'Annual revenue'),
696
+ meta: {
697
+ columnChooserGroup: 'Profile',
698
+ hidden: true,
699
+ filterKey: 'company_profile.annual_revenue',
700
+ filterType: 'number' as const,
701
+ },
702
+ cell: ({ row }) => row.original.annualRevenue || noValue,
703
+ },
704
+ {
705
+ accessorKey: 'description',
706
+ header: t('customers.companies.detail.fields.description', 'Description'),
707
+ meta: { columnChooserGroup: 'Notes', hidden: true, filterKey: 'description' },
708
+ cell: ({ row }) => row.original.description || noValue,
709
+ },
565
710
  ]
566
711
 
567
- const customColumns = filterCustomFieldDefs(customFieldDefs, 'list').map<ColumnDef<CompanyRow>>((def) => ({
568
- accessorKey: `cf_${def.key}`,
569
- header: def.label || def.key,
570
- cell: ({ getValue }) => renderCustomFieldCell(getValue()),
571
- }))
712
+ const customColumns = customFieldDefs
713
+ .filter((def) => supportsCustomFieldColumn(def))
714
+ .map<ColumnDef<CompanyRow>>((def) => ({
715
+ accessorKey: `cf_${def.key}`,
716
+ header: def.label || def.key,
717
+ meta: {
718
+ columnChooserGroup: def.group?.title ?? 'Custom Fields',
719
+ filterGroup: def.group?.title ?? 'Custom Fields',
720
+ filterType: mapCustomFieldKindToFilterType(def.kind),
721
+ filterOptions: normalizeCustomFieldFilterOptions(def.options),
722
+ hidden: def.listVisible === false,
723
+ },
724
+ cell: ({ getValue }) => renderCustomFieldCell(getValue()),
725
+ }))
572
726
 
573
727
  return [...baseColumns, ...customColumns]
574
728
  }, [customFieldDefs, dictionaryMaps, t])
@@ -577,6 +731,7 @@ export default function CustomersCompaniesPage() {
577
731
  <Page>
578
732
  <PageBody>
579
733
  <DataTable<CompanyRow>
734
+ stickyFirstColumn
580
735
  title={t('customers.companies.list.title')}
581
736
  refreshButton={{
582
737
  label: t('customers.companies.list.actions.refresh'),
@@ -590,6 +745,7 @@ export default function CustomersCompaniesPage() {
590
745
  </Button>
591
746
  )}
592
747
  columns={columns}
748
+ columnChooser={{ auto: true }}
593
749
  data={rows}
594
750
  exporter={exportConfig}
595
751
  searchValue={search}
@@ -602,6 +758,17 @@ export default function CustomersCompaniesPage() {
602
758
  entityIds={[E.customers.customer_entity, E.customers.customer_company_profile]}
603
759
  onRowClick={(row) => router.push(`/backend/customers/companies-v2/${row.id}`)}
604
760
  perspective={{ tableId: 'customers.companies.list' }}
761
+ sortable
762
+ sorting={sorting}
763
+ onSortingChange={setSorting}
764
+ bulkActions={[
765
+ {
766
+ id: 'delete',
767
+ label: t('customers.companies.list.actions.bulkDelete', 'Delete selected'),
768
+ destructive: true,
769
+ onExecute: handleBulkDelete,
770
+ },
771
+ ]}
605
772
  rowActions={(row) => (
606
773
  <RowActions
607
774
  items={[
@@ -624,7 +791,15 @@ export default function CustomersCompaniesPage() {
624
791
  ]}
625
792
  />
626
793
  )}
627
- pagination={{ page, pageSize, total, totalPages, onPageChange: setPage, cacheStatus }}
794
+ advancedFilter={{
795
+ auto: true,
796
+ value: advancedFilterState,
797
+ onChange: setAdvancedFilterState,
798
+ onApply: () => { setPage(1) },
799
+ onClear: () => { setAdvancedFilterState({ logic: 'and', conditions: [] }); setPage(1) },
800
+ }}
801
+ virtualized
802
+ pagination={{ page, pageSize, total, totalPages, onPageChange: setPage, pageSizeOptions: [10, 25, 50, 100], onPageSizeChange: handlePageSizeChange, cacheStatus }}
628
803
  isLoading={isLoading}
629
804
  />
630
805
  </PageBody>