@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
|
@@ -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(
|
|
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 =
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
if (
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
|
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 =
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
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,
|