@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.
- package/dist/modules/customers/api/companies/route.js +141 -3
- package/dist/modules/customers/api/companies/route.js.map +2 -2
- package/dist/modules/customers/api/deals/route.js +52 -3
- package/dist/modules/customers/api/deals/route.js.map +2 -2
- package/dist/modules/customers/api/people/route.js +145 -3
- package/dist/modules/customers/api/people/route.js.map +2 -2
- package/dist/modules/customers/api/utils.js +195 -0
- package/dist/modules/customers/api/utils.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies/page.js +171 -6
- package/dist/modules/customers/backend/customers/companies/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/page.js +100 -7
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people/page.js +180 -7
- package/dist/modules/customers/backend/customers/people/page.js.map +2 -2
- package/dist/modules/customers/commands/interactions.js +7 -0
- package/dist/modules/customers/commands/interactions.js.map +2 -2
- package/dist/modules/customers/components/detail/DealForm.js +1 -0
- package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +81 -1
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/package.json +3 -3
- package/src/modules/customers/api/companies/route.ts +151 -3
- package/src/modules/customers/api/deals/route.ts +54 -3
- package/src/modules/customers/api/people/route.ts +160 -3
- package/src/modules/customers/api/utils.ts +286 -0
- package/src/modules/customers/backend/customers/companies/page.tsx +184 -9
- package/src/modules/customers/backend/customers/deals/page.tsx +127 -35
- package/src/modules/customers/backend/customers/people/page.tsx +191 -10
- package/src/modules/customers/commands/interactions.ts +7 -0
- package/src/modules/customers/components/detail/DealForm.tsx +1 -0
- package/src/modules/customers/i18n/de.json +12 -0
- package/src/modules/customers/i18n/en.json +15 -3
- package/src/modules/customers/i18n/es.json +12 -0
- package/src/modules/customers/i18n/pl.json +12 -0
- 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 =
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
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>
|