@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
@@ -8,6 +8,8 @@ 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
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 { serializeAdvancedFilter } from '@open-mercato/shared/lib/query/advanced-filter'
11
13
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
12
14
  import { buildCrudExportUrl, deleteCrud } from '@open-mercato/ui/backend/utils/crud'
13
15
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
@@ -28,8 +30,12 @@ import {
28
30
  } from '../../../components/detail/hooks/useCustomerDictionary'
29
31
  import {
30
32
  useCustomFieldDefs,
31
- filterCustomFieldDefs,
32
33
  } from '@open-mercato/ui/backend/utils/customFieldDefs'
34
+ import {
35
+ mapCustomFieldKindToFilterType,
36
+ normalizeCustomFieldFilterOptions,
37
+ supportsCustomFieldColumn,
38
+ } from '@open-mercato/ui/backend/utils/customFieldColumns'
33
39
 
34
40
  type DealRow = {
35
41
  id: string
@@ -276,6 +282,8 @@ export default function CustomersDealsPage() {
276
282
  const raw = Number(searchParams?.get('page') ?? '1')
277
283
  return Number.isFinite(raw) && raw > 0 ? raw : 1
278
284
  })
285
+ const [pageSize, setPageSize] = React.useState(PAGE_SIZE)
286
+ const [sorting, setSorting] = React.useState<import('@tanstack/react-table').SortingState>([])
279
287
  const [total, setTotal] = React.useState(0)
280
288
  const [totalPages, setTotalPages] = React.useState(1)
281
289
  const [search, setSearch] = React.useState(() => searchParams?.get('search')?.trim() ?? '')
@@ -283,6 +291,7 @@ export default function CustomersDealsPage() {
283
291
  const [reloadToken, setReloadToken] = React.useState(0)
284
292
  const [pendingDeleteId, setPendingDeleteId] = React.useState<string | null>(null)
285
293
  const [filterValues, setFilterValues] = React.useState<FilterValues>({})
294
+ const [advancedFilterState, setAdvancedFilterState] = React.useState<AdvancedFilterState>({ logic: 'and', conditions: [] })
286
295
  const [cacheStatus, setCacheStatus] = React.useState<'hit' | 'miss' | null>(null)
287
296
 
288
297
  const initialPersonIds = React.useMemo(
@@ -580,7 +589,11 @@ export default function CustomersDealsPage() {
580
589
  const queryParams = React.useMemo(() => {
581
590
  const params = new URLSearchParams()
582
591
  params.set('page', String(page))
583
- params.set('pageSize', String(PAGE_SIZE))
592
+ params.set('pageSize', String(pageSize))
593
+ if (sorting.length > 0) {
594
+ params.set('sort', sorting[0].id)
595
+ params.set('order', sorting[0].desc ? 'desc' : 'asc')
596
+ }
584
597
  if (search.trim().length) params.set('search', search.trim())
585
598
  if (selectedPersonIds.length) params.set('personId', selectedPersonIds.join(','))
586
599
  if (selectedCompanyIds.length) params.set('companyId', selectedCompanyIds.join(','))
@@ -607,8 +620,12 @@ export default function CustomersDealsPage() {
607
620
  if (stringValue) params.set(key, stringValue)
608
621
  }
609
622
  })
623
+ const advancedParams = serializeAdvancedFilter(advancedFilterState)
624
+ for (const [key, val] of Object.entries(advancedParams)) {
625
+ params.set(key, val)
626
+ }
610
627
  return params.toString()
611
- }, [filterValues, page, search, selectedCompanyIds, selectedPersonIds])
628
+ }, [advancedFilterState, filterValues, page, pageSize, search, selectedCompanyIds, selectedPersonIds, sorting])
612
629
 
613
630
  const currentParams = React.useMemo(
614
631
  () => Object.fromEntries(new URLSearchParams(queryParams)),
@@ -731,6 +748,39 @@ export default function CustomersDealsPage() {
731
748
  [confirm, handleRefresh, pendingDeleteId, t],
732
749
  )
733
750
 
751
+ const handlePageSizeChange = React.useCallback((newSize: number) => {
752
+ setPageSize(newSize)
753
+ setPage(1)
754
+ }, [])
755
+
756
+ const handleBulkDelete = React.useCallback(async (selectedRows: DealRow[]) => {
757
+ const confirmed = await confirm({
758
+ title: t('customers.deals.list.bulkDelete.title', 'Delete {count} deals?', { count: selectedRows.length }),
759
+ description: t('customers.deals.list.bulkDelete.description', 'This action cannot be undone.'),
760
+ variant: 'destructive',
761
+ })
762
+ if (!confirmed) return false
763
+ let deletedCount = 0
764
+ for (const row of selectedRows) {
765
+ try {
766
+ await deleteCrud('customers/deals', {
767
+ body: { id: row.id },
768
+ errorMessage: t('customers.deals.list.deleteError', 'Failed to delete deal.'),
769
+ })
770
+ deletedCount++
771
+ } catch {}
772
+ }
773
+ if (deletedCount > 0) {
774
+ setRows((prev) => {
775
+ const deletedIds = new Set(selectedRows.map((r) => r.id))
776
+ return prev.filter((r) => !deletedIds.has(r.id))
777
+ })
778
+ setTotal((prev) => Math.max(0, prev - deletedCount))
779
+ flash(t('customers.deals.list.bulkDelete.success', '{count} deals deleted', { count: deletedCount }), 'success')
780
+ }
781
+ return deletedCount > 0
782
+ }, [confirm, t])
783
+
734
784
  const personOptions = peopleState.options
735
785
  const companyOptions = companiesState.options
736
786
 
@@ -786,57 +836,70 @@ export default function CustomersDealsPage() {
786
836
  )
787
837
  }
788
838
 
789
- const customColumns = filterCustomFieldDefs(customFieldDefs, 'list').map<ColumnDef<DealRow>>((def) => ({
790
- accessorKey: `cf_${def.key}`,
791
- header: def.label || def.key,
792
- cell: ({ getValue }) => {
793
- const value = getValue()
794
- if (value == null) return noValue
795
- if (Array.isArray(value)) {
796
- const normalized = value
797
- .map((item) => {
798
- if (item == null) return ''
799
- if (typeof item === 'string') return item.trim()
800
- return String(item).trim()
801
- })
802
- .filter((item) => item.length > 0)
803
- if (!normalized.length) return noValue
804
- return <span className="text-sm">{normalized.join(', ')}</span>
805
- }
806
- if (typeof value === 'boolean') {
807
- return (
808
- <span className="text-sm">
809
- {value
810
- ? t('customers.deals.list.booleanYes', 'Yes')
811
- : t('customers.deals.list.booleanNo', 'No')}
812
- </span>
813
- )
814
- }
815
- const stringValue = typeof value === 'string' ? value.trim() : String(value)
816
- if (!stringValue) return noValue
817
- return <span className="text-sm">{stringValue}</span>
818
- },
819
- }))
839
+ const customColumns = customFieldDefs
840
+ .filter((def) => supportsCustomFieldColumn(def))
841
+ .map<ColumnDef<DealRow>>((def) => ({
842
+ accessorKey: `cf_${def.key}`,
843
+ header: def.label || def.key,
844
+ meta: {
845
+ columnChooserGroup: def.group?.title ?? 'Custom Fields',
846
+ filterGroup: def.group?.title ?? 'Custom Fields',
847
+ filterType: mapCustomFieldKindToFilterType(def.kind),
848
+ filterOptions: normalizeCustomFieldFilterOptions(def.options),
849
+ hidden: def.listVisible === false,
850
+ },
851
+ cell: ({ getValue }) => {
852
+ const value = getValue()
853
+ if (value == null) return noValue
854
+ if (Array.isArray(value)) {
855
+ const normalized = value
856
+ .map((item) => {
857
+ if (item == null) return ''
858
+ if (typeof item === 'string') return item.trim()
859
+ return String(item).trim()
860
+ })
861
+ .filter((item) => item.length > 0)
862
+ if (!normalized.length) return noValue
863
+ return <span className="text-sm">{normalized.join(', ')}</span>
864
+ }
865
+ if (typeof value === 'boolean') {
866
+ return (
867
+ <span className="text-sm">
868
+ {value
869
+ ? t('customers.deals.list.booleanYes', 'Yes')
870
+ : t('customers.deals.list.booleanNo', 'No')}
871
+ </span>
872
+ )
873
+ }
874
+ const stringValue = typeof value === 'string' ? value.trim() : String(value)
875
+ if (!stringValue) return noValue
876
+ return <span className="text-sm">{stringValue}</span>
877
+ },
878
+ }))
820
879
 
821
880
  return [
822
881
  {
823
882
  accessorKey: 'title',
824
883
  header: t('customers.deals.list.columns.title'),
884
+ meta: { alwaysVisible: true, columnChooserGroup: 'Basic Info', filterKey: 'title' },
825
885
  cell: ({ row }) => <span className="font-medium text-sm">{row.original.title}</span>,
826
886
  },
827
887
  {
828
888
  accessorKey: 'status',
829
889
  header: t('customers.deals.list.columns.status'),
890
+ meta: { filterType: 'select' as const, columnChooserGroup: 'Basic Info', filterKey: 'status' },
830
891
  cell: ({ row }) => renderDictionaryCell('deal-statuses', row.original.status),
831
892
  },
832
893
  {
833
894
  accessorKey: 'pipelineStage',
834
895
  header: t('customers.deals.list.columns.pipelineStage'),
896
+ meta: { columnChooserGroup: 'Pipeline', filterKey: 'pipeline_stage' },
835
897
  cell: ({ row }) => renderDictionaryCell('pipeline-stages', row.original.pipelineStage),
836
898
  },
837
899
  {
838
900
  accessorKey: 'pipelineId',
839
901
  header: t('customers.deals.list.columns.pipeline', 'Pipeline'),
902
+ meta: { columnChooserGroup: 'Pipeline', filterKey: 'pipeline_id' },
840
903
  cell: ({ row }) => {
841
904
  const name = row.original.pipelineId ? pipelineNames[row.original.pipelineId] : null
842
905
  return name ? <span className="text-sm">{name}</span> : noValue
@@ -845,6 +908,7 @@ export default function CustomersDealsPage() {
845
908
  {
846
909
  accessorKey: 'valueAmount',
847
910
  header: t('customers.deals.list.columns.value'),
911
+ meta: { filterType: 'number' as const, columnChooserGroup: 'Financial', filterKey: 'value_amount' },
848
912
  cell: ({ row }) => (
849
913
  <span className="text-sm font-medium">
850
914
  {formatCurrency(row.original.valueAmount ?? null, row.original.valueCurrency ?? null, t('customers.deals.list.noValue'))}
@@ -854,6 +918,7 @@ export default function CustomersDealsPage() {
854
918
  {
855
919
  accessorKey: 'probability',
856
920
  header: t('customers.deals.list.columns.probability'),
921
+ meta: { filterType: 'number' as const, columnChooserGroup: 'Financial', filterKey: 'probability' },
857
922
  cell: ({ row }) => {
858
923
  const value = row.original.probability
859
924
  if (typeof value === 'number' && Number.isFinite(value)) {
@@ -865,6 +930,7 @@ export default function CustomersDealsPage() {
865
930
  {
866
931
  accessorKey: 'expectedCloseAt',
867
932
  header: t('customers.deals.list.columns.expectedClose'),
933
+ meta: { columnChooserGroup: 'Dates', filterKey: 'expected_close_at' },
868
934
  cell: ({ row }) => (
869
935
  <span className="text-sm">
870
936
  {formatDateValue(row.original.expectedCloseAt ?? null, t('customers.deals.list.noValue'))}
@@ -874,16 +940,19 @@ export default function CustomersDealsPage() {
874
940
  {
875
941
  accessorKey: 'companies',
876
942
  header: t('customers.deals.list.columns.companies'),
943
+ meta: { columnChooserGroup: 'Associations', filterable: false },
877
944
  cell: ({ row }) => renderAssociationList(row.original.companies, t('customers.deals.list.unnamedCompany')),
878
945
  },
879
946
  {
880
947
  accessorKey: 'people',
881
948
  header: t('customers.deals.list.columns.people'),
949
+ meta: { columnChooserGroup: 'Associations', filterable: false },
882
950
  cell: ({ row }) => renderAssociationList(row.original.people, t('customers.deals.list.unnamedPerson')),
883
951
  },
884
952
  {
885
953
  accessorKey: 'updatedAt',
886
954
  header: t('customers.deals.list.columns.updatedAt'),
955
+ meta: { columnChooserGroup: 'Dates', filterKey: 'updated_at' },
887
956
  cell: ({ row }) => (
888
957
  <span className="text-sm">
889
958
  {formatDateValue(row.original.updatedAt ?? null, t('customers.deals.list.noValue'))}
@@ -898,6 +967,7 @@ export default function CustomersDealsPage() {
898
967
  <Page>
899
968
  <PageBody>
900
969
  <DataTable<DealRow>
970
+ stickyFirstColumn
901
971
  title={t('customers.deals.list.title')}
902
972
  actions={(
903
973
  <Button asChild>
@@ -907,6 +977,7 @@ export default function CustomersDealsPage() {
907
977
  </Button>
908
978
  )}
909
979
  columns={columns}
980
+ columnChooser={{ auto: true }}
910
981
  data={rows}
911
982
  onRowClick={(row) => {
912
983
  router.push(`/backend/customers/deals/${row.id}`)
@@ -942,6 +1013,17 @@ export default function CustomersDealsPage() {
942
1013
  />
943
1014
  )
944
1015
  }}
1016
+ sortable
1017
+ sorting={sorting}
1018
+ onSortingChange={setSorting}
1019
+ bulkActions={[
1020
+ {
1021
+ id: 'delete',
1022
+ label: t('customers.deals.list.actions.delete', 'Delete'),
1023
+ destructive: true,
1024
+ onExecute: handleBulkDelete,
1025
+ },
1026
+ ]}
945
1027
  searchValue={search}
946
1028
  onSearchChange={handleSearchChange}
947
1029
  searchPlaceholder={t('customers.deals.list.searchPlaceholder')}
@@ -951,10 +1033,12 @@ export default function CustomersDealsPage() {
951
1033
  onFiltersClear={handleFiltersClear}
952
1034
  pagination={{
953
1035
  page,
954
- pageSize: PAGE_SIZE,
1036
+ pageSize,
955
1037
  total,
956
1038
  totalPages,
957
1039
  onPageChange: (nextPage) => setPage(nextPage),
1040
+ pageSizeOptions: [10, 25, 50, 100],
1041
+ onPageSizeChange: handlePageSizeChange,
958
1042
  cacheStatus,
959
1043
  }}
960
1044
  isLoading={isLoading}
@@ -965,6 +1049,14 @@ export default function CustomersDealsPage() {
965
1049
  exporter={exportConfig}
966
1050
  entityId={E.customers.customer_deal}
967
1051
  perspective={{ tableId: 'customers.deals.list' }}
1052
+ advancedFilter={{
1053
+ auto: true,
1054
+ value: advancedFilterState,
1055
+ onChange: setAdvancedFilterState,
1056
+ onApply: () => { setPage(1) },
1057
+ onClear: () => { setAdvancedFilterState({ logic: 'and', conditions: [] }); setPage(1) },
1058
+ }}
1059
+ virtualized
968
1060
  />
969
1061
  </PageBody>
970
1062
  {ConfirmDialogElement}
@@ -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,16 @@ type PersonRow = {
37
43
  description?: string | null
38
44
  email?: string | null
39
45
  phone?: string | null
46
+ firstName?: string | null
47
+ lastName?: string | null
48
+ preferredName?: string | null
49
+ jobTitle?: string | null
50
+ department?: string | null
51
+ seniority?: string | null
52
+ timezone?: string | null
53
+ linkedInUrl?: string | null
54
+ twitterUrl?: string | null
55
+ companyEntityId?: string | null
40
56
  status?: string | null
41
57
  lifecycleStage?: string | null
42
58
  nextInteractionAt?: string | null
@@ -87,6 +103,16 @@ function mapApiItem(item: Record<string, unknown>): PersonRow | null {
87
103
  const description = typeof item.description === 'string' ? item.description : null
88
104
  const email = typeof item.primary_email === 'string' ? item.primary_email : null
89
105
  const phone = typeof item.primary_phone === 'string' ? item.primary_phone : null
106
+ const firstName = typeof item.first_name === 'string' ? item.first_name : null
107
+ const lastName = typeof item.last_name === 'string' ? item.last_name : null
108
+ const preferredName = typeof item.preferred_name === 'string' ? item.preferred_name : null
109
+ const jobTitle = typeof item.job_title === 'string' ? item.job_title : null
110
+ const department = typeof item.department === 'string' ? item.department : null
111
+ const seniority = typeof item.seniority === 'string' ? item.seniority : null
112
+ const timezone = typeof item.timezone === 'string' ? item.timezone : null
113
+ const linkedInUrl = typeof item.linked_in_url === 'string' ? item.linked_in_url : null
114
+ const twitterUrl = typeof item.twitter_url === 'string' ? item.twitter_url : null
115
+ const companyEntityId = typeof item.company_entity_id === 'string' ? item.company_entity_id : null
90
116
  const status = typeof item.status === 'string' ? item.status : null
91
117
  const lifecycleStage = typeof item.lifecycle_stage === 'string' ? item.lifecycle_stage : null
92
118
  const nextInteractionAt = typeof item.next_interaction_at === 'string' ? item.next_interaction_at : null
@@ -107,6 +133,16 @@ function mapApiItem(item: Record<string, unknown>): PersonRow | null {
107
133
  description,
108
134
  email,
109
135
  phone,
136
+ firstName,
137
+ lastName,
138
+ preferredName,
139
+ jobTitle,
140
+ department,
141
+ seniority,
142
+ timezone,
143
+ linkedInUrl,
144
+ twitterUrl,
145
+ companyEntityId,
110
146
  status,
111
147
  lifecycleStage,
112
148
  nextInteractionAt,
@@ -123,11 +159,13 @@ export default function CustomersPeoplePage() {
123
159
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
124
160
  const [rows, setRows] = React.useState<PersonRow[]>([])
125
161
  const [page, setPage] = React.useState(1)
126
- const [pageSize] = React.useState(20)
162
+ const [pageSize, setPageSize] = React.useState(20)
163
+ const [sorting, setSorting] = React.useState<import('@tanstack/react-table').SortingState>([])
127
164
  const [total, setTotal] = React.useState(0)
128
165
  const [totalPages, setTotalPages] = React.useState(1)
129
166
  const [search, setSearch] = React.useState('')
130
167
  const [filterValues, setFilterValues] = React.useState<FilterValues>({})
168
+ const [advancedFilterState, setAdvancedFilterState] = React.useState<AdvancedFilterState>({ logic: 'and', conditions: [] })
131
169
  const [isLoading, setIsLoading] = React.useState(true)
132
170
  const [reloadToken, setReloadToken] = React.useState(0)
133
171
  const [cacheStatus, setCacheStatus] = React.useState<'hit' | 'miss' | null>(null)
@@ -137,6 +175,10 @@ export default function CustomersPeoplePage() {
137
175
  const queryClient = useQueryClient()
138
176
  const t = useT()
139
177
  const router = useRouter()
178
+ const handlePageSizeChange = React.useCallback((newSize: number) => {
179
+ setPageSize(newSize)
180
+ setPage(1)
181
+ }, [])
140
182
  const fetchDictionaryEntries = React.useCallback(async (kind: DictionaryKindKey) => {
141
183
  try {
142
184
  const data = await ensureCustomerDictionary(queryClient, kind, scopeVersion)
@@ -305,6 +347,10 @@ export default function CustomersPeoplePage() {
305
347
  const params = new URLSearchParams()
306
348
  params.set('page', String(page))
307
349
  params.set('pageSize', String(pageSize))
350
+ if (sorting.length > 0) {
351
+ params.set('sort', sorting[0].id)
352
+ params.set('order', sorting[0].desc ? 'desc' : 'asc')
353
+ }
308
354
  if (search.trim()) params.set('search', search.trim())
309
355
  const status = filterValues.status
310
356
  if (typeof status === 'string' && status.trim()) params.set('status', status)
@@ -364,8 +410,12 @@ export default function CustomersPeoplePage() {
364
410
  if (stringValue) params.set(key, stringValue)
365
411
  }
366
412
  })
413
+ const advancedParams = serializeAdvancedFilter(advancedFilterState)
414
+ for (const [key, val] of Object.entries(advancedParams)) {
415
+ params.set(key, val)
416
+ }
367
417
  return params.toString()
368
- }, [filterValues, page, pageSize, search, tagIdToLabel, tagLabelToId])
418
+ }, [advancedFilterState, filterValues, page, pageSize, search, sorting, tagIdToLabel, tagLabelToId])
369
419
 
370
420
  const currentParams = React.useMemo(() => Object.fromEntries(new URLSearchParams(queryParams)), [queryParams])
371
421
  const exportConfig = React.useMemo(() => ({
@@ -446,6 +496,35 @@ export default function CustomersPeoplePage() {
446
496
  }
447
497
  }, [confirm, handleRefresh, t])
448
498
 
499
+ const handleBulkDelete = React.useCallback(async (selectedRows: PersonRow[]) => {
500
+ const confirmed = await confirm({
501
+ title: t('customers.people.list.bulkDelete.title', 'Delete {count} people?', { count: selectedRows.length }),
502
+ description: t('customers.people.list.bulkDelete.description', 'This action cannot be undone.'),
503
+ variant: 'destructive',
504
+ })
505
+ if (!confirmed) return false
506
+ let deletedCount = 0
507
+ for (const row of selectedRows) {
508
+ try {
509
+ await apiCallOrThrow(`/api/customers/people?id=${encodeURIComponent(row.id)}`, {
510
+ method: 'DELETE',
511
+ headers: { 'content-type': 'application/json' },
512
+ })
513
+ deletedCount++
514
+ } catch {}
515
+ }
516
+ if (deletedCount > 0) {
517
+ setRows((prev) => {
518
+ const deletedIds = new Set(selectedRows.map((r) => r.id))
519
+ return prev.filter((r) => !deletedIds.has(r.id))
520
+ })
521
+ setTotal((prev) => Math.max(0, prev - deletedCount))
522
+ flash(t('customers.people.list.bulkDelete.success', '{count} people deleted', { count: deletedCount }), 'success')
523
+ setReloadToken((prev) => prev + 1)
524
+ }
525
+ return deletedCount > 0
526
+ }, [confirm, t])
527
+
449
528
  const handleFiltersApply = React.useCallback((values: FilterValues) => {
450
529
  const next: FilterValues = {}
451
530
  Object.entries(values).forEach(([key, value]) => {
@@ -518,6 +597,7 @@ export default function CustomersPeoplePage() {
518
597
  {
519
598
  accessorKey: 'name',
520
599
  header: t('customers.people.list.columns.name'),
600
+ meta: { alwaysVisible: true, columnChooserGroup: 'Basic Info', filterKey: 'display_name' },
521
601
  cell: ({ row }) => (
522
602
  <Link href={`/backend/customers/people-v2/${row.original.id}`} className="font-medium hover:underline">
523
603
  {row.original.name}
@@ -527,22 +607,32 @@ export default function CustomersPeoplePage() {
527
607
  {
528
608
  accessorKey: 'email',
529
609
  header: t('customers.people.list.columns.email'),
610
+ meta: { columnChooserGroup: 'Contact', filterKey: 'primary_email' },
530
611
  cell: ({ row }) => row.original.email || <span className="text-muted-foreground text-sm">{t('customers.people.list.noValue')}</span>,
531
612
  },
532
613
  {
533
614
  accessorKey: 'status',
534
615
  header: t('customers.people.list.columns.status'),
616
+ meta: { filterType: 'select' as const, filterOptions: dictionaryOptions.statuses, columnChooserGroup: 'Basic Info' },
535
617
  cell: ({ row }) => renderDictionaryCell('statuses', row.original.status),
536
618
  },
537
619
  {
538
620
  accessorKey: 'lifecycleStage',
539
621
  header: t('customers.people.list.columns.lifecycleStage'),
622
+ meta: {
623
+ filterType: 'select' as const,
624
+ filterOptions: dictionaryOptions.lifecycleStages,
625
+ columnChooserGroup: 'Basic Info',
626
+ filterKey: 'lifecycle_stage',
627
+ },
540
628
  cell: ({ row }) => renderDictionaryCell('lifecycle-stages', row.original.lifecycleStage),
541
629
  },
542
630
  {
543
631
  accessorKey: 'nextInteractionAt',
544
632
  header: t('customers.people.list.columns.nextInteraction'),
545
633
  meta: {
634
+ columnChooserGroup: 'Dates',
635
+ filterKey: 'next_interaction_at',
546
636
  tooltipContent: (row: PersonRow) => {
547
637
  if (!row.nextInteractionAt) return undefined
548
638
  const date = formatDate(row.nextInteractionAt, '')
@@ -577,23 +667,94 @@ export default function CustomersPeoplePage() {
577
667
  {
578
668
  accessorKey: 'source',
579
669
  header: t('customers.people.list.columns.source'),
670
+ meta: { filterType: 'select' as const, filterOptions: dictionaryOptions.sources, columnChooserGroup: 'Basic Info' },
580
671
  cell: ({ row }) => renderDictionaryCell('sources', row.original.source),
581
672
  },
673
+ {
674
+ accessorKey: 'firstName',
675
+ header: t('customers.people.form.firstName', 'First name'),
676
+ meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'person_profile.first_name' },
677
+ cell: ({ row }) => row.original.firstName || noValue,
678
+ },
679
+ {
680
+ accessorKey: 'lastName',
681
+ header: t('customers.people.form.lastName', 'Last name'),
682
+ meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'person_profile.last_name' },
683
+ cell: ({ row }) => row.original.lastName || noValue,
684
+ },
685
+ {
686
+ accessorKey: 'preferredName',
687
+ header: t('customers.people.form.preferredName', 'Preferred name'),
688
+ meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'person_profile.preferred_name' },
689
+ cell: ({ row }) => row.original.preferredName || noValue,
690
+ },
691
+ {
692
+ accessorKey: 'jobTitle',
693
+ header: t('customers.people.form.jobTitle', 'Job title'),
694
+ meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'person_profile.job_title' },
695
+ cell: ({ row }) => row.original.jobTitle || noValue,
696
+ },
697
+ {
698
+ accessorKey: 'department',
699
+ header: t('customers.people.detail.fields.department', 'Department'),
700
+ meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'person_profile.department' },
701
+ cell: ({ row }) => row.original.department || noValue,
702
+ },
703
+ {
704
+ accessorKey: 'seniority',
705
+ header: t('customers.people.detail.fields.seniority', 'Seniority'),
706
+ meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'person_profile.seniority' },
707
+ cell: ({ row }) => row.original.seniority || noValue,
708
+ },
709
+ {
710
+ accessorKey: 'timezone',
711
+ header: t('customers.people.detail.fields.timezone', 'Timezone'),
712
+ meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'person_profile.timezone' },
713
+ cell: ({ row }) => row.original.timezone || noValue,
714
+ },
715
+ {
716
+ accessorKey: 'linkedInUrl',
717
+ header: t('customers.people.detail.fields.linkedIn', 'LinkedIn'),
718
+ meta: { columnChooserGroup: 'Socials', hidden: true, filterKey: 'person_profile.linked_in_url' },
719
+ cell: ({ row }) => row.original.linkedInUrl || noValue,
720
+ },
721
+ {
722
+ accessorKey: 'twitterUrl',
723
+ header: t('customers.people.detail.fields.twitter', 'Twitter'),
724
+ meta: { columnChooserGroup: 'Socials', hidden: true, filterKey: 'person_profile.twitter_url' },
725
+ cell: ({ row }) => row.original.twitterUrl || noValue,
726
+ },
727
+ {
728
+ accessorKey: 'description',
729
+ header: t('customers.people.form.description', 'Description'),
730
+ meta: { columnChooserGroup: 'Notes', hidden: true, filterKey: 'description' },
731
+ cell: ({ row }) => row.original.description || noValue,
732
+ },
582
733
  ]
583
734
 
584
- const customColumns = filterCustomFieldDefs(customFieldDefs, 'list').map<ColumnDef<PersonRow>>((def) => ({
585
- accessorKey: `cf_${def.key}`,
586
- header: def.label || def.key,
587
- cell: ({ getValue }) => renderCustomFieldCell(getValue()),
588
- }))
735
+ const customColumns = customFieldDefs
736
+ .filter((def) => supportsCustomFieldColumn(def))
737
+ .map<ColumnDef<PersonRow>>((def) => ({
738
+ accessorKey: `cf_${def.key}`,
739
+ header: def.label || def.key,
740
+ meta: {
741
+ columnChooserGroup: def.group?.title ?? 'Custom Fields',
742
+ filterGroup: def.group?.title ?? 'Custom Fields',
743
+ filterType: mapCustomFieldKindToFilterType(def.kind),
744
+ filterOptions: normalizeCustomFieldFilterOptions(def.options),
745
+ hidden: def.listVisible === false,
746
+ },
747
+ cell: ({ getValue }) => renderCustomFieldCell(getValue()),
748
+ }))
589
749
 
590
750
  return [...baseColumns, ...customColumns]
591
- }, [customFieldDefs, dictionaryMaps, t])
751
+ }, [customFieldDefs, dictionaryMaps, dictionaryOptions, t])
592
752
 
593
753
  return (
594
754
  <Page>
595
755
  <PageBody>
596
756
  <DataTable<PersonRow>
757
+ stickyFirstColumn
597
758
  title={t('customers.people.list.title')}
598
759
  refreshButton={{
599
760
  label: t('customers.people.list.actions.refresh'),
@@ -607,6 +768,7 @@ export default function CustomersPeoplePage() {
607
768
  </Button>
608
769
  )}
609
770
  columns={columns}
771
+ columnChooser={{ auto: true }}
610
772
  data={rows}
611
773
  exporter={exportConfig}
612
774
  searchValue={search}
@@ -619,6 +781,17 @@ export default function CustomersPeoplePage() {
619
781
  entityIds={[E.customers.customer_entity, E.customers.customer_person_profile]}
620
782
  perspective={{ tableId: 'customers.people.list' }}
621
783
  onRowClick={(row) => router.push(`/backend/customers/people-v2/${row.id}`)}
784
+ sortable
785
+ sorting={sorting}
786
+ onSortingChange={setSorting}
787
+ bulkActions={[
788
+ {
789
+ id: 'delete',
790
+ label: t('customers.people.list.bulkDelete.action', 'Delete selected'),
791
+ destructive: true,
792
+ onExecute: handleBulkDelete,
793
+ },
794
+ ]}
622
795
  rowActions={(row) => (
623
796
  <RowActions
624
797
  items={[
@@ -641,7 +814,15 @@ export default function CustomersPeoplePage() {
641
814
  ]}
642
815
  />
643
816
  )}
644
- pagination={{ page, pageSize, total, totalPages, onPageChange: setPage, cacheStatus }}
817
+ advancedFilter={{
818
+ auto: true,
819
+ value: advancedFilterState,
820
+ onChange: setAdvancedFilterState,
821
+ onApply: () => { setPage(1) },
822
+ onClear: () => { setAdvancedFilterState({ logic: 'and', conditions: [] }); setPage(1) },
823
+ }}
824
+ virtualized
825
+ pagination={{ page, pageSize, total, totalPages, onPageChange: setPage, cacheStatus, pageSizeOptions: [10, 25, 50, 100], onPageSizeChange: handlePageSizeChange }}
645
826
  isLoading={isLoading}
646
827
  />
647
828
  </PageBody>
@@ -28,6 +28,7 @@ import {
28
28
  ensureTenantScope,
29
29
  requireCustomerEntity,
30
30
  extractUndoPayload,
31
+ emitQueryIndexUpsertEvents,
31
32
  requireDealInScope,
32
33
  resolveParentResourceKind,
33
34
  } from './shared'
@@ -233,6 +234,12 @@ async function emitNextInteractionUpdatedEvent(
233
234
  projection: InteractionProjectionMutation,
234
235
  identifiers: InteractionIdentifiers,
235
236
  ): Promise<void> {
237
+ await emitQueryIndexUpsertEvents(ctx, [{
238
+ entityType: 'customers:customer_entity',
239
+ recordId: projection.entityId,
240
+ organizationId: identifiers.organizationId,
241
+ tenantId: identifiers.tenantId,
242
+ }])
236
243
  await emitLifecycleEvent(ctx, 'customers.next_interaction.updated', {
237
244
  id: projection.entityId,
238
245
  entityId: projection.entityId,