@open-mercato/core 0.6.6-develop.6385.1.9a81faa5f0 → 0.6.6-develop.6387.1.f4896ad565
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/attachments/lib/scope.js +26 -0
- package/dist/modules/attachments/lib/scope.js.map +7 -0
- package/dist/modules/catalog/commands/variants.js +4 -2
- package/dist/modules/catalog/commands/variants.js.map +2 -2
- package/dist/modules/customers/api/deals/map/route.js +433 -0
- package/dist/modules/customers/api/deals/map/route.js.map +7 -0
- package/dist/modules/customers/backend/customers/companies/create/page.js +2 -0
- package/dist/modules/customers/backend/customers/companies/create/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.js +159 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.js +26 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js +321 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js +533 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/map/page.js +55 -0
- package/dist/modules/customers/backend/customers/deals/map/page.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/map/page.meta.js +38 -0
- package/dist/modules/customers/backend/customers/deals/map/page.meta.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +3 -3
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +22 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +2 -2
- package/dist/modules/customers/backend/customers/people/create/page.js +2 -0
- package/dist/modules/customers/backend/customers/people/create/page.js.map +2 -2
- package/dist/modules/customers/components/AddressEditor.js +34 -2
- package/dist/modules/customers/components/AddressEditor.js.map +2 -2
- package/dist/modules/customers/components/AddressTiles.js +43 -3
- package/dist/modules/customers/components/AddressTiles.js.map +2 -2
- package/dist/modules/customers/components/detail/AddressesSection.js +4 -1
- package/dist/modules/customers/components/detail/AddressesSection.js.map +2 -2
- package/dist/modules/customers/components/formConfig.js +10 -0
- package/dist/modules/customers/components/formConfig.js.map +2 -2
- package/dist/modules/customers/data/validators.js +3 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/dealsMapLocation.js +75 -0
- package/dist/modules/customers/lib/dealsMapLocation.js.map +7 -0
- package/dist/modules/messages/commands/messages.js +0 -1
- package/dist/modules/messages/commands/messages.js.map +2 -2
- package/dist/modules/messages/lib/attachments.js +13 -7
- package/dist/modules/messages/lib/attachments.js.map +2 -2
- package/package.json +11 -7
- package/src/modules/attachments/lib/scope.ts +42 -0
- package/src/modules/catalog/commands/variants.ts +7 -2
- package/src/modules/customers/api/deals/map/route.ts +549 -0
- package/src/modules/customers/backend/customers/companies/create/page.tsx +2 -0
- package/src/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.tsx +213 -0
- package/src/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.tsx +79 -0
- package/src/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.tsx +407 -0
- package/src/modules/customers/backend/customers/deals/map/components/DealsMapView.tsx +667 -0
- package/src/modules/customers/backend/customers/deals/map/page.meta.ts +36 -0
- package/src/modules/customers/backend/customers/deals/map/page.tsx +77 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +3 -3
- package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +22 -1
- package/src/modules/customers/backend/customers/people/create/page.tsx +2 -0
- package/src/modules/customers/components/AddressEditor.tsx +35 -0
- package/src/modules/customers/components/AddressTiles.tsx +51 -1
- package/src/modules/customers/components/detail/AddressesSection.tsx +3 -0
- package/src/modules/customers/components/formConfig.tsx +10 -0
- package/src/modules/customers/data/validators.ts +13 -2
- package/src/modules/customers/i18n/de.json +24 -0
- package/src/modules/customers/i18n/en.json +24 -0
- package/src/modules/customers/i18n/es.json +24 -0
- package/src/modules/customers/i18n/pl.json +24 -0
- package/src/modules/customers/lib/dealsMapLocation.ts +125 -0
- package/src/modules/messages/commands/messages.ts +0 -1
- package/src/modules/messages/lib/attachments.ts +15 -7
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { useQuery } from '@tanstack/react-query'
|
|
5
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
6
|
+
import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
|
|
7
|
+
import {
|
|
8
|
+
apiCall,
|
|
9
|
+
readApiResultOrThrow,
|
|
10
|
+
withScopedApiRequestHeaders,
|
|
11
|
+
} from '@open-mercato/ui/backend/utils/apiCall'
|
|
12
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
13
|
+
import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
|
|
14
|
+
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
15
|
+
import type { FilterOptionTone } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
16
|
+
import { FilterBarRow, type KanbanFilterChip } from '../../pipeline/components/FilterBarRow'
|
|
17
|
+
import { StatusFilterPopover } from '../../pipeline/components/StatusFilterPopover'
|
|
18
|
+
import {
|
|
19
|
+
PipelineFilterPopover,
|
|
20
|
+
type PipelineFilterOption,
|
|
21
|
+
} from '../../pipeline/components/PipelineFilterPopover'
|
|
22
|
+
import {
|
|
23
|
+
EntityFilterPopover,
|
|
24
|
+
type EntityFilterOption,
|
|
25
|
+
} from '../../pipeline/components/EntityFilterPopover'
|
|
26
|
+
import {
|
|
27
|
+
CloseDateFilterPopover,
|
|
28
|
+
type CloseDateRange,
|
|
29
|
+
} from '../../pipeline/components/CloseDateFilterPopover'
|
|
30
|
+
import { SortByPopover, type SortOption } from '../../pipeline/components/SortByPopover'
|
|
31
|
+
import { fetchAssignableStaffMembers } from '../../../../../components/detail/assignableStaff'
|
|
32
|
+
import { DealsLocationPanel } from './DealsLocationPanel'
|
|
33
|
+
import {
|
|
34
|
+
DealsMapCanvas,
|
|
35
|
+
type DealsMapCanvasDeal,
|
|
36
|
+
type DealsMapLegendStage,
|
|
37
|
+
type DealsMapPreview,
|
|
38
|
+
type MapCenter,
|
|
39
|
+
} from './DealsMapCanvas'
|
|
40
|
+
|
|
41
|
+
const DEFAULT_SORT: SortOption = 'updated_desc'
|
|
42
|
+
const PAGE_SIZE = 100
|
|
43
|
+
const MAX_DEALS = 500
|
|
44
|
+
const FALLBACK_TONES: FilterOptionTone[] = ['success', 'warning', 'error', 'info', 'neutral', 'brand']
|
|
45
|
+
const KNOWN_STAGE_TONES: ReadonlySet<string> = new Set([
|
|
46
|
+
'success',
|
|
47
|
+
'warning',
|
|
48
|
+
'info',
|
|
49
|
+
'error',
|
|
50
|
+
'neutral',
|
|
51
|
+
'brand',
|
|
52
|
+
'pink',
|
|
53
|
+
])
|
|
54
|
+
const SUPPRESS_AUTH_REDIRECT_HEADERS = {
|
|
55
|
+
'x-om-forbidden-redirect': '0',
|
|
56
|
+
'x-om-unauthorized-redirect': '0',
|
|
57
|
+
} as const
|
|
58
|
+
// The map has no quick-filter chips (filters live in the leading popovers), but FilterBarRow
|
|
59
|
+
// requires the props — keep stable module-level references instead of per-render allocations.
|
|
60
|
+
const NO_FILTER_CHIPS: KanbanFilterChip[] = []
|
|
61
|
+
const noopChipClick = (_chipId: KanbanFilterChip['id']) => {}
|
|
62
|
+
|
|
63
|
+
type PipelineRecord = { id: string; name: string; isDefault: boolean }
|
|
64
|
+
|
|
65
|
+
type PipelineStageRecord = {
|
|
66
|
+
id: string
|
|
67
|
+
pipelineId: string
|
|
68
|
+
label: string
|
|
69
|
+
order: number
|
|
70
|
+
color?: string | null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type StageMeta = { label: string; tone: FilterOptionTone }
|
|
74
|
+
|
|
75
|
+
type DealMapApiLocation = {
|
|
76
|
+
latitude?: number | string | null
|
|
77
|
+
longitude?: number | string | null
|
|
78
|
+
city?: string | null
|
|
79
|
+
region?: string | null
|
|
80
|
+
country?: string | null
|
|
81
|
+
source?: string | null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type DealMapApiItem = {
|
|
85
|
+
id?: string
|
|
86
|
+
title?: string | null
|
|
87
|
+
status?: string | null
|
|
88
|
+
pipelineId?: string | null
|
|
89
|
+
pipelineStageId?: string | null
|
|
90
|
+
pipelineStage?: string | null
|
|
91
|
+
valueAmount?: number | string | null
|
|
92
|
+
valueCurrency?: string | null
|
|
93
|
+
probability?: number | string | null
|
|
94
|
+
expectedCloseAt?: string | null
|
|
95
|
+
ownerUserId?: string | null
|
|
96
|
+
updatedAt?: string | null
|
|
97
|
+
companies?: Array<{ id?: string; label?: string } | null> | null
|
|
98
|
+
people?: Array<{ id?: string; label?: string } | null> | null
|
|
99
|
+
location?: DealMapApiLocation | null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type DealsMapApiResponse = {
|
|
103
|
+
items?: DealMapApiItem[]
|
|
104
|
+
total?: number
|
|
105
|
+
page?: number
|
|
106
|
+
pageSize?: number
|
|
107
|
+
totalPages?: number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type MapDealLocation = {
|
|
111
|
+
latitude: number
|
|
112
|
+
longitude: number
|
|
113
|
+
city: string | null
|
|
114
|
+
region: string | null
|
|
115
|
+
country: string | null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type MapDeal = {
|
|
119
|
+
id: string
|
|
120
|
+
title: string
|
|
121
|
+
status: string | null
|
|
122
|
+
pipelineId: string | null
|
|
123
|
+
pipelineStageId: string | null
|
|
124
|
+
valueAmount: number | null
|
|
125
|
+
valueCurrency: string | null
|
|
126
|
+
probability: number | null
|
|
127
|
+
expectedCloseAt: string | null
|
|
128
|
+
ownerUserId: string | null
|
|
129
|
+
companyLabel: string | null
|
|
130
|
+
location: MapDealLocation | null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type DealsMapQueryResult = {
|
|
134
|
+
deals: MapDeal[]
|
|
135
|
+
total: number
|
|
136
|
+
truncated: boolean
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizeNumber(value: unknown): number | null {
|
|
140
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
141
|
+
if (typeof value === 'string' && value.trim().length) {
|
|
142
|
+
const parsed = Number(value)
|
|
143
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
144
|
+
}
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function normalizeText(value: unknown): string | null {
|
|
149
|
+
return typeof value === 'string' && value.trim().length ? value.trim() : null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeLocation(location: DealMapApiLocation | null | undefined): MapDealLocation | null {
|
|
153
|
+
if (!location) return null
|
|
154
|
+
const latitude = normalizeNumber(location.latitude)
|
|
155
|
+
const longitude = normalizeNumber(location.longitude)
|
|
156
|
+
if (latitude === null || longitude === null) return null
|
|
157
|
+
return {
|
|
158
|
+
latitude,
|
|
159
|
+
longitude,
|
|
160
|
+
city: normalizeText(location.city),
|
|
161
|
+
region: normalizeText(location.region),
|
|
162
|
+
country: normalizeText(location.country),
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function mapApiItem(item: DealMapApiItem, fallbackTitle: string): MapDeal | null {
|
|
167
|
+
const id = normalizeText(item.id)
|
|
168
|
+
if (!id) return null
|
|
169
|
+
const firstCompany = Array.isArray(item.companies)
|
|
170
|
+
? item.companies.find((entry) => entry && normalizeText(entry.label))
|
|
171
|
+
: null
|
|
172
|
+
return {
|
|
173
|
+
id,
|
|
174
|
+
title: normalizeText(item.title) ?? fallbackTitle,
|
|
175
|
+
status: normalizeText(item.status),
|
|
176
|
+
pipelineId: normalizeText(item.pipelineId),
|
|
177
|
+
pipelineStageId: normalizeText(item.pipelineStageId),
|
|
178
|
+
valueAmount: normalizeNumber(item.valueAmount),
|
|
179
|
+
valueCurrency: normalizeText(item.valueCurrency)?.toUpperCase() ?? null,
|
|
180
|
+
probability: normalizeNumber(item.probability),
|
|
181
|
+
expectedCloseAt: normalizeText(item.expectedCloseAt),
|
|
182
|
+
ownerUserId: normalizeText(item.ownerUserId),
|
|
183
|
+
companyLabel: firstCompany ? normalizeText(firstCompany.label) : null,
|
|
184
|
+
location: normalizeLocation(item.location),
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function mapSortOptionToApi(option: SortOption): { sortField: string; sortDir: 'asc' | 'desc' } | null {
|
|
189
|
+
switch (option) {
|
|
190
|
+
case 'updated_desc':
|
|
191
|
+
return { sortField: 'updatedAt', sortDir: 'desc' }
|
|
192
|
+
case 'updated_asc':
|
|
193
|
+
return { sortField: 'updatedAt', sortDir: 'asc' }
|
|
194
|
+
case 'created_desc':
|
|
195
|
+
return { sortField: 'createdAt', sortDir: 'desc' }
|
|
196
|
+
case 'value_desc':
|
|
197
|
+
return { sortField: 'value', sortDir: 'desc' }
|
|
198
|
+
case 'value_asc':
|
|
199
|
+
return { sortField: 'value', sortDir: 'asc' }
|
|
200
|
+
case 'probability_desc':
|
|
201
|
+
return { sortField: 'probability', sortDir: 'desc' }
|
|
202
|
+
case 'close_asc':
|
|
203
|
+
return { sortField: 'expectedCloseAt', sortDir: 'asc' }
|
|
204
|
+
case 'owner_asc':
|
|
205
|
+
default:
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function buildStageMetaById(stages: PipelineStageRecord[]): Map<string, StageMeta> {
|
|
211
|
+
const byPipeline = new Map<string, PipelineStageRecord[]>()
|
|
212
|
+
for (const stage of stages) {
|
|
213
|
+
const key = stage.pipelineId ?? ''
|
|
214
|
+
const bucket = byPipeline.get(key) ?? []
|
|
215
|
+
bucket.push(stage)
|
|
216
|
+
byPipeline.set(key, bucket)
|
|
217
|
+
}
|
|
218
|
+
const meta = new Map<string, StageMeta>()
|
|
219
|
+
for (const bucket of byPipeline.values()) {
|
|
220
|
+
const sorted = bucket.slice().sort((a, b) => a.order - b.order)
|
|
221
|
+
sorted.forEach((stage, index) => {
|
|
222
|
+
const tone =
|
|
223
|
+
stage.color && KNOWN_STAGE_TONES.has(stage.color)
|
|
224
|
+
? (stage.color as FilterOptionTone)
|
|
225
|
+
: FALLBACK_TONES[index % FALLBACK_TONES.length]
|
|
226
|
+
meta.set(stage.id, { label: stage.label, tone })
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
return meta
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
type DealsMapViewProps = {
|
|
233
|
+
search: string
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function DealsMapView({ search }: DealsMapViewProps): React.ReactElement {
|
|
237
|
+
const t = useT()
|
|
238
|
+
const scopeVersion = useOrganizationScopeVersion()
|
|
239
|
+
|
|
240
|
+
const [statusFilters, setStatusFilters] = React.useState<string[]>([])
|
|
241
|
+
const [selectedPipelineId, setSelectedPipelineId] = React.useState<string | null>(null)
|
|
242
|
+
const [ownerFilters, setOwnerFilters] = React.useState<string[]>([])
|
|
243
|
+
const [peopleFilters, setPeopleFilters] = React.useState<string[]>([])
|
|
244
|
+
const [companyFilters, setCompanyFilters] = React.useState<string[]>([])
|
|
245
|
+
const [closeDateFilter, setCloseDateFilter] = React.useState<CloseDateRange>({ from: null, to: null })
|
|
246
|
+
const [sortBy, setSortBy] = React.useState<SortOption>(DEFAULT_SORT)
|
|
247
|
+
const [ownerLabels, setOwnerLabels] = React.useState<Record<string, string>>({})
|
|
248
|
+
const [peopleLabels, setPeopleLabels] = React.useState<Record<string, string>>({})
|
|
249
|
+
const [companyLabels, setCompanyLabels] = React.useState<Record<string, string>>({})
|
|
250
|
+
const [selectedDealId, setSelectedDealId] = React.useState<string | null>(null)
|
|
251
|
+
const [mapCenter, setMapCenter] = React.useState<MapCenter | null>(null)
|
|
252
|
+
|
|
253
|
+
const [debouncedSearch, setDebouncedSearch] = React.useState(search)
|
|
254
|
+
React.useEffect(() => {
|
|
255
|
+
const handle = window.setTimeout(() => setDebouncedSearch(search.trim()), 300)
|
|
256
|
+
return () => window.clearTimeout(handle)
|
|
257
|
+
}, [search])
|
|
258
|
+
|
|
259
|
+
const fallbackTitle = translateWithFallback(t, 'customers.deals.pipeline.untitled', 'Untitled deal')
|
|
260
|
+
const loadErrorLabel = translateWithFallback(
|
|
261
|
+
t,
|
|
262
|
+
'customers.deals.map.loadError',
|
|
263
|
+
'Failed to load deals for the map.',
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
const apiSort = React.useMemo(() => {
|
|
267
|
+
const mapped = mapSortOptionToApi(sortBy)
|
|
268
|
+
return mapped ?? { sortField: 'updatedAt' as const, sortDir: 'desc' as const }
|
|
269
|
+
}, [sortBy])
|
|
270
|
+
|
|
271
|
+
const pipelinesQuery = useQuery<PipelineRecord[]>({
|
|
272
|
+
queryKey: ['customers', 'deals', 'map', 'pipelines', `scope:${scopeVersion}`],
|
|
273
|
+
staleTime: 60_000,
|
|
274
|
+
queryFn: async () => {
|
|
275
|
+
const call = await apiCall<{ items?: PipelineRecord[] }>('/api/customers/pipelines', {
|
|
276
|
+
headers: SUPPRESS_AUTH_REDIRECT_HEADERS,
|
|
277
|
+
})
|
|
278
|
+
if (!call.ok) return []
|
|
279
|
+
return Array.isArray(call.result?.items) ? call.result.items : []
|
|
280
|
+
},
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
const stagesQuery = useQuery<PipelineStageRecord[]>({
|
|
284
|
+
queryKey: ['customers', 'deals', 'map', 'stages', `scope:${scopeVersion}`],
|
|
285
|
+
staleTime: 60_000,
|
|
286
|
+
queryFn: async () => {
|
|
287
|
+
const call = await apiCall<{ items?: PipelineStageRecord[] }>('/api/customers/pipeline-stages', {
|
|
288
|
+
headers: SUPPRESS_AUTH_REDIRECT_HEADERS,
|
|
289
|
+
})
|
|
290
|
+
if (!call.ok) return []
|
|
291
|
+
return Array.isArray(call.result?.items) ? call.result.items : []
|
|
292
|
+
},
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
const staffQuery = useQuery<Map<string, string>>({
|
|
296
|
+
queryKey: ['customers', 'deals', 'map', 'staff', `scope:${scopeVersion}`],
|
|
297
|
+
staleTime: 300_000,
|
|
298
|
+
queryFn: async () => {
|
|
299
|
+
try {
|
|
300
|
+
const members = await withScopedApiRequestHeaders(
|
|
301
|
+
{ ...SUPPRESS_AUTH_REDIRECT_HEADERS },
|
|
302
|
+
() => fetchAssignableStaffMembers('', { pageSize: 100 }),
|
|
303
|
+
)
|
|
304
|
+
const names = new Map<string, string>()
|
|
305
|
+
for (const member of members) {
|
|
306
|
+
if (member.userId && member.displayName) names.set(member.userId, member.displayName)
|
|
307
|
+
}
|
|
308
|
+
return names
|
|
309
|
+
} catch {
|
|
310
|
+
return new Map<string, string>()
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
const ownerNamesById = staffQuery.data ?? null
|
|
316
|
+
|
|
317
|
+
const dealsQuery = useQuery<DealsMapQueryResult>({
|
|
318
|
+
queryKey: [
|
|
319
|
+
'customers',
|
|
320
|
+
'deals',
|
|
321
|
+
'map',
|
|
322
|
+
'deals',
|
|
323
|
+
`scope:${scopeVersion}`,
|
|
324
|
+
`search:${debouncedSearch}`,
|
|
325
|
+
`status:${statusFilters.slice().sort().join(',')}`,
|
|
326
|
+
`pipeline:${selectedPipelineId ?? ''}`,
|
|
327
|
+
`owners:${ownerFilters.slice().sort().join(',')}`,
|
|
328
|
+
`people:${peopleFilters.slice().sort().join(',')}`,
|
|
329
|
+
`companies:${companyFilters.slice().sort().join(',')}`,
|
|
330
|
+
`close:${closeDateFilter.from ?? ''}-${closeDateFilter.to ?? ''}`,
|
|
331
|
+
`sort:${apiSort.sortField}:${apiSort.sortDir}`,
|
|
332
|
+
],
|
|
333
|
+
staleTime: 30_000,
|
|
334
|
+
queryFn: async () => {
|
|
335
|
+
const buildParams = (page: number) => {
|
|
336
|
+
const params = new URLSearchParams()
|
|
337
|
+
params.set('page', String(page))
|
|
338
|
+
params.set('pageSize', String(PAGE_SIZE))
|
|
339
|
+
params.set('sortField', apiSort.sortField)
|
|
340
|
+
params.set('sortDir', apiSort.sortDir)
|
|
341
|
+
if (debouncedSearch.length) params.set('search', debouncedSearch)
|
|
342
|
+
for (const status of statusFilters) params.append('status', status)
|
|
343
|
+
if (selectedPipelineId) params.set('pipelineId', selectedPipelineId)
|
|
344
|
+
for (const ownerId of ownerFilters) params.append('ownerUserId', ownerId)
|
|
345
|
+
for (const personId of peopleFilters) params.append('personId', personId)
|
|
346
|
+
for (const companyId of companyFilters) params.append('companyId', companyId)
|
|
347
|
+
if (closeDateFilter.from) params.set('expectedCloseAtFrom', closeDateFilter.from)
|
|
348
|
+
if (closeDateFilter.to) params.set('expectedCloseAtTo', closeDateFilter.to)
|
|
349
|
+
return params
|
|
350
|
+
}
|
|
351
|
+
const fetchPage = (page: number) =>
|
|
352
|
+
readApiResultOrThrow<DealsMapApiResponse>(
|
|
353
|
+
`/api/customers/deals/map?${buildParams(page).toString()}`,
|
|
354
|
+
undefined,
|
|
355
|
+
{ errorMessage: loadErrorLabel },
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
const collected: MapDeal[] = []
|
|
359
|
+
const collectFrom = (payload: DealsMapApiResponse | null | undefined) => {
|
|
360
|
+
const items = Array.isArray(payload?.items) ? payload.items : []
|
|
361
|
+
for (const item of items) {
|
|
362
|
+
const mapped = mapApiItem(item, fallbackTitle)
|
|
363
|
+
if (mapped) collected.push(mapped)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Fetch the first page to learn totalPages, then pull the remaining pages concurrently —
|
|
368
|
+
// the pages are independent, so awaiting them sequentially only stacked round-trip latency.
|
|
369
|
+
const firstPage = await fetchPage(1)
|
|
370
|
+
collectFrom(firstPage)
|
|
371
|
+
const total = typeof firstPage?.total === 'number' ? firstPage.total : collected.length
|
|
372
|
+
const reportedTotalPages = typeof firstPage?.totalPages === 'number' ? firstPage.totalPages : 1
|
|
373
|
+
// Bound the fan-out: the client renders at most MAX_DEALS pins regardless of the located total.
|
|
374
|
+
const maxPages = Math.max(1, Math.ceil(MAX_DEALS / PAGE_SIZE))
|
|
375
|
+
const lastPage = Math.min(reportedTotalPages, maxPages)
|
|
376
|
+
if (lastPage > 1) {
|
|
377
|
+
const rest = await Promise.all(
|
|
378
|
+
Array.from({ length: lastPage - 1 }, (_unused, index) => fetchPage(index + 2)),
|
|
379
|
+
)
|
|
380
|
+
for (const payload of rest) collectFrom(payload)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const deals = collected.slice(0, MAX_DEALS)
|
|
384
|
+
return { deals, total, truncated: total > deals.length }
|
|
385
|
+
},
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
const stageMetaById = React.useMemo(
|
|
389
|
+
() => buildStageMetaById(stagesQuery.data ?? []),
|
|
390
|
+
[stagesQuery.data],
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
const deals = React.useMemo(() => {
|
|
394
|
+
const fetched = dealsQuery.data?.deals ?? []
|
|
395
|
+
if (sortBy !== 'owner_asc' || !ownerNamesById) return fetched
|
|
396
|
+
return fetched.slice().sort((a, b) => {
|
|
397
|
+
const aLabel = (a.ownerUserId ? ownerNamesById.get(a.ownerUserId) : null) ?? a.ownerUserId ?? ''
|
|
398
|
+
const bLabel = (b.ownerUserId ? ownerNamesById.get(b.ownerUserId) : null) ?? b.ownerUserId ?? ''
|
|
399
|
+
return aLabel.localeCompare(bLabel)
|
|
400
|
+
})
|
|
401
|
+
}, [dealsQuery.data, sortBy, ownerNamesById])
|
|
402
|
+
|
|
403
|
+
const locatedDeals = React.useMemo(
|
|
404
|
+
() => deals.filter((deal): deal is MapDeal & { location: MapDealLocation } => deal.location !== null),
|
|
405
|
+
[deals],
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
const canvasDeals = React.useMemo<DealsMapCanvasDeal[]>(
|
|
409
|
+
() =>
|
|
410
|
+
locatedDeals.map((deal) => ({
|
|
411
|
+
id: deal.id,
|
|
412
|
+
latitude: deal.location.latitude,
|
|
413
|
+
longitude: deal.location.longitude,
|
|
414
|
+
tone: deal.pipelineStageId ? stageMetaById.get(deal.pipelineStageId)?.tone ?? null : null,
|
|
415
|
+
})),
|
|
416
|
+
[locatedDeals, stageMetaById],
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
const legendStages = React.useMemo<DealsMapLegendStage[]>(() => {
|
|
420
|
+
if (stageMetaById.size === 0) return []
|
|
421
|
+
const presentStageIds = new Set<string>()
|
|
422
|
+
for (const deal of locatedDeals) {
|
|
423
|
+
if (deal.pipelineStageId) presentStageIds.add(deal.pipelineStageId)
|
|
424
|
+
}
|
|
425
|
+
const stages: DealsMapLegendStage[] = []
|
|
426
|
+
for (const stage of stagesQuery.data ?? []) {
|
|
427
|
+
if (!presentStageIds.has(stage.id)) continue
|
|
428
|
+
const meta = stageMetaById.get(stage.id)
|
|
429
|
+
if (!meta) continue
|
|
430
|
+
stages.push({ id: stage.id, label: meta.label, tone: meta.tone })
|
|
431
|
+
}
|
|
432
|
+
return stages
|
|
433
|
+
}, [locatedDeals, stageMetaById, stagesQuery.data])
|
|
434
|
+
|
|
435
|
+
const selectedDeal = React.useMemo(
|
|
436
|
+
() => (selectedDealId ? deals.find((deal) => deal.id === selectedDealId) ?? null : null),
|
|
437
|
+
[deals, selectedDealId],
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
const preview = React.useMemo<DealsMapPreview | null>(() => {
|
|
441
|
+
if (!selectedDeal) return null
|
|
442
|
+
const stageMeta = selectedDeal.pipelineStageId
|
|
443
|
+
? stageMetaById.get(selectedDeal.pipelineStageId) ?? null
|
|
444
|
+
: null
|
|
445
|
+
const locationLine = selectedDeal.location
|
|
446
|
+
? [selectedDeal.location.city, selectedDeal.location.region].filter(Boolean).join(', ') ||
|
|
447
|
+
selectedDeal.location.country
|
|
448
|
+
: null
|
|
449
|
+
return {
|
|
450
|
+
id: selectedDeal.id,
|
|
451
|
+
title: selectedDeal.title,
|
|
452
|
+
companyLabel: selectedDeal.companyLabel,
|
|
453
|
+
locationLine: locationLine || null,
|
|
454
|
+
valueAmount: selectedDeal.valueAmount,
|
|
455
|
+
valueCurrency: selectedDeal.valueCurrency,
|
|
456
|
+
probability: selectedDeal.probability,
|
|
457
|
+
expectedCloseAt: selectedDeal.expectedCloseAt,
|
|
458
|
+
stageLabel: stageMeta?.label ?? null,
|
|
459
|
+
stageTone: stageMeta?.tone ?? null,
|
|
460
|
+
ownerName:
|
|
461
|
+
selectedDeal.ownerUserId && ownerNamesById
|
|
462
|
+
? ownerNamesById.get(selectedDeal.ownerUserId) ?? null
|
|
463
|
+
: null,
|
|
464
|
+
}
|
|
465
|
+
}, [selectedDeal, stageMetaById, ownerNamesById])
|
|
466
|
+
|
|
467
|
+
const handleSelect = React.useCallback((dealId: string | null) => {
|
|
468
|
+
setSelectedDealId(dealId)
|
|
469
|
+
}, [])
|
|
470
|
+
|
|
471
|
+
const handleCenterChange = React.useCallback((center: MapCenter) => {
|
|
472
|
+
setMapCenter(center)
|
|
473
|
+
}, [])
|
|
474
|
+
|
|
475
|
+
const pipelineFilterOptions = React.useMemo<PipelineFilterOption[]>(
|
|
476
|
+
() => (pipelinesQuery.data ?? []).map((pipeline) => ({ id: pipeline.id, name: pipeline.name })),
|
|
477
|
+
[pipelinesQuery.data],
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
const loadOwnerOptions = React.useCallback(
|
|
481
|
+
async (query: string, _signal: AbortSignal): Promise<EntityFilterOption[]> => {
|
|
482
|
+
try {
|
|
483
|
+
const members = await withScopedApiRequestHeaders(
|
|
484
|
+
{ ...SUPPRESS_AUTH_REDIRECT_HEADERS },
|
|
485
|
+
() => fetchAssignableStaffMembers(query ?? '', { pageSize: 100 }),
|
|
486
|
+
)
|
|
487
|
+
const options: EntityFilterOption[] = members
|
|
488
|
+
.filter((member) => !!member.userId && !!member.displayName)
|
|
489
|
+
.map((member) => ({ value: member.userId, label: member.displayName }))
|
|
490
|
+
setOwnerLabels((prev) => {
|
|
491
|
+
const next: Record<string, string> = { ...prev }
|
|
492
|
+
for (const option of options) next[option.value] = option.label
|
|
493
|
+
return next
|
|
494
|
+
})
|
|
495
|
+
return options
|
|
496
|
+
} catch {
|
|
497
|
+
return []
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
[],
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
const loadPeopleOptions = React.useCallback(
|
|
504
|
+
async (query: string, signal: AbortSignal): Promise<EntityFilterOption[]> => {
|
|
505
|
+
const params = new URLSearchParams()
|
|
506
|
+
params.set('page', '1')
|
|
507
|
+
params.set('pageSize', '50')
|
|
508
|
+
if (query) params.set('search', query)
|
|
509
|
+
params.set('sortField', 'displayName')
|
|
510
|
+
params.set('sortDir', 'asc')
|
|
511
|
+
const call = await apiCall<{
|
|
512
|
+
items?: Array<{ id?: string; display_name?: string; first_name?: string; last_name?: string }>
|
|
513
|
+
}>(`/api/customers/people?${params.toString()}`, {
|
|
514
|
+
signal,
|
|
515
|
+
headers: SUPPRESS_AUTH_REDIRECT_HEADERS,
|
|
516
|
+
})
|
|
517
|
+
if (!call.ok) return []
|
|
518
|
+
const items = call.result?.items ?? []
|
|
519
|
+
const options: EntityFilterOption[] = []
|
|
520
|
+
for (const item of items) {
|
|
521
|
+
if (!item.id) continue
|
|
522
|
+
const label =
|
|
523
|
+
item.display_name && item.display_name.trim().length
|
|
524
|
+
? item.display_name.trim()
|
|
525
|
+
: [item.first_name, item.last_name].filter(Boolean).join(' ').trim() || item.id.slice(0, 8)
|
|
526
|
+
options.push({ value: item.id, label })
|
|
527
|
+
}
|
|
528
|
+
setPeopleLabels((prev) => {
|
|
529
|
+
const next: Record<string, string> = { ...prev }
|
|
530
|
+
for (const option of options) next[option.value] = option.label
|
|
531
|
+
return next
|
|
532
|
+
})
|
|
533
|
+
return options
|
|
534
|
+
},
|
|
535
|
+
[],
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
const loadCompanyOptions = React.useCallback(
|
|
539
|
+
async (query: string, signal: AbortSignal): Promise<EntityFilterOption[]> => {
|
|
540
|
+
const params = new URLSearchParams()
|
|
541
|
+
params.set('page', '1')
|
|
542
|
+
params.set('pageSize', '50')
|
|
543
|
+
if (query) params.set('search', query)
|
|
544
|
+
params.set('sortField', 'display_name')
|
|
545
|
+
params.set('sortDir', 'asc')
|
|
546
|
+
const call = await apiCall<{ items?: Array<{ id?: string; display_name?: string }> }>(
|
|
547
|
+
`/api/customers/companies?${params.toString()}`,
|
|
548
|
+
{ signal, headers: SUPPRESS_AUTH_REDIRECT_HEADERS },
|
|
549
|
+
)
|
|
550
|
+
if (!call.ok) return []
|
|
551
|
+
const items = call.result?.items ?? []
|
|
552
|
+
const options: EntityFilterOption[] = []
|
|
553
|
+
for (const item of items) {
|
|
554
|
+
if (!item.id || !item.display_name) continue
|
|
555
|
+
options.push({ value: item.id, label: item.display_name })
|
|
556
|
+
}
|
|
557
|
+
setCompanyLabels((prev) => {
|
|
558
|
+
const next: Record<string, string> = { ...prev }
|
|
559
|
+
for (const option of options) next[option.value] = option.label
|
|
560
|
+
return next
|
|
561
|
+
})
|
|
562
|
+
return options
|
|
563
|
+
},
|
|
564
|
+
[],
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
const leadingChipsNode = (
|
|
568
|
+
<>
|
|
569
|
+
<StatusFilterPopover values={statusFilters} onApply={setStatusFilters} />
|
|
570
|
+
<PipelineFilterPopover
|
|
571
|
+
pipelines={pipelineFilterOptions}
|
|
572
|
+
selectedPipelineId={selectedPipelineId}
|
|
573
|
+
onApply={setSelectedPipelineId}
|
|
574
|
+
/>
|
|
575
|
+
<EntityFilterPopover
|
|
576
|
+
label={translateWithFallback(t, 'customers.deals.kanban.filter.owner', 'Owner')}
|
|
577
|
+
anyLabel={translateWithFallback(t, 'customers.deals.kanban.filter.all', 'All')}
|
|
578
|
+
values={ownerFilters}
|
|
579
|
+
onApply={setOwnerFilters}
|
|
580
|
+
loadOptions={loadOwnerOptions}
|
|
581
|
+
labelById={ownerLabels}
|
|
582
|
+
/>
|
|
583
|
+
<EntityFilterPopover
|
|
584
|
+
label={translateWithFallback(t, 'customers.deals.kanban.filter.people', 'People')}
|
|
585
|
+
values={peopleFilters}
|
|
586
|
+
onApply={setPeopleFilters}
|
|
587
|
+
loadOptions={loadPeopleOptions}
|
|
588
|
+
labelById={peopleLabels}
|
|
589
|
+
/>
|
|
590
|
+
<EntityFilterPopover
|
|
591
|
+
label={translateWithFallback(t, 'customers.deals.kanban.filter.companies', 'Companies')}
|
|
592
|
+
values={companyFilters}
|
|
593
|
+
onApply={setCompanyFilters}
|
|
594
|
+
loadOptions={loadCompanyOptions}
|
|
595
|
+
labelById={companyLabels}
|
|
596
|
+
/>
|
|
597
|
+
<CloseDateFilterPopover value={closeDateFilter} onApply={setCloseDateFilter} />
|
|
598
|
+
</>
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
const sortNode = <SortByPopover value={sortBy} onApply={setSortBy} />
|
|
602
|
+
|
|
603
|
+
return (
|
|
604
|
+
<div className="flex flex-col gap-4">
|
|
605
|
+
<FilterBarRow
|
|
606
|
+
leadingChips={leadingChipsNode}
|
|
607
|
+
chips={NO_FILTER_CHIPS}
|
|
608
|
+
sortNode={sortNode}
|
|
609
|
+
onChipClick={noopChipClick}
|
|
610
|
+
/>
|
|
611
|
+
|
|
612
|
+
{dealsQuery.isLoading ? (
|
|
613
|
+
<LoadingMessage
|
|
614
|
+
label={translateWithFallback(t, 'customers.deals.map.canvas.loading', 'Loading map…')}
|
|
615
|
+
/>
|
|
616
|
+
) : dealsQuery.isError ? (
|
|
617
|
+
<ErrorMessage
|
|
618
|
+
label={dealsQuery.error instanceof Error ? dealsQuery.error.message : loadErrorLabel}
|
|
619
|
+
action={
|
|
620
|
+
<Button
|
|
621
|
+
type="button"
|
|
622
|
+
variant="outline"
|
|
623
|
+
size="sm"
|
|
624
|
+
onClick={() => void dealsQuery.refetch()}
|
|
625
|
+
>
|
|
626
|
+
{translateWithFallback(t, 'customers.deals.map.retry', 'Retry')}
|
|
627
|
+
</Button>
|
|
628
|
+
}
|
|
629
|
+
/>
|
|
630
|
+
) : (
|
|
631
|
+
<div className="flex flex-col gap-2">
|
|
632
|
+
{dealsQuery.data?.truncated ? (
|
|
633
|
+
<p className="text-sm text-muted-foreground">
|
|
634
|
+
{translateWithFallback(
|
|
635
|
+
t,
|
|
636
|
+
'customers.deals.map.truncated',
|
|
637
|
+
'Showing first {count} of {total} located deals — refine filters to narrow the map.',
|
|
638
|
+
{ count: deals.length, total: dealsQuery.data?.total ?? deals.length },
|
|
639
|
+
)}
|
|
640
|
+
</p>
|
|
641
|
+
) : null}
|
|
642
|
+
<div className="flex min-h-[480px] flex-col gap-4 lg:h-[calc(100vh-360px)] lg:flex-row">
|
|
643
|
+
<DealsLocationPanel
|
|
644
|
+
deals={deals}
|
|
645
|
+
locatedCount={dealsQuery.data?.total ?? deals.length}
|
|
646
|
+
stageMetaById={stageMetaById}
|
|
647
|
+
mapCenter={mapCenter}
|
|
648
|
+
selectedDealId={selectedDealId}
|
|
649
|
+
onSelect={handleSelect}
|
|
650
|
+
/>
|
|
651
|
+
<DealsMapCanvas
|
|
652
|
+
className="min-h-[480px] flex-1 overflow-hidden rounded-xl border border-border lg:min-h-0"
|
|
653
|
+
deals={canvasDeals}
|
|
654
|
+
legendStages={legendStages}
|
|
655
|
+
preview={preview}
|
|
656
|
+
selectedDealId={selectedDealId}
|
|
657
|
+
onSelect={handleSelect}
|
|
658
|
+
onCenterChange={handleCenterChange}
|
|
659
|
+
/>
|
|
660
|
+
</div>
|
|
661
|
+
</div>
|
|
662
|
+
)}
|
|
663
|
+
</div>
|
|
664
|
+
)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export default DealsMapView
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
const mapIcon = React.createElement(
|
|
4
|
+
'svg',
|
|
5
|
+
{
|
|
6
|
+
width: 16,
|
|
7
|
+
height: 16,
|
|
8
|
+
viewBox: '0 0 24 24',
|
|
9
|
+
fill: 'none',
|
|
10
|
+
stroke: 'currentColor',
|
|
11
|
+
strokeWidth: 2,
|
|
12
|
+
strokeLinecap: 'round',
|
|
13
|
+
strokeLinejoin: 'round',
|
|
14
|
+
},
|
|
15
|
+
React.createElement('path', {
|
|
16
|
+
d: 'M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z',
|
|
17
|
+
}),
|
|
18
|
+
React.createElement('path', { d: 'M15 5.764v15' }),
|
|
19
|
+
React.createElement('path', { d: 'M9 3.236v15' })
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
export const metadata = {
|
|
23
|
+
requireAuth: true,
|
|
24
|
+
requireFeatures: ['customers.deals.view', 'customers.activities.view'],
|
|
25
|
+
pageTitle: 'Deals Map',
|
|
26
|
+
pageTitleKey: 'customers.nav.deals.map',
|
|
27
|
+
pageGroup: 'Customers',
|
|
28
|
+
pageGroupKey: 'customers.nav.group',
|
|
29
|
+
pagePriority: 10,
|
|
30
|
+
pageOrder: 122,
|
|
31
|
+
icon: mapIcon,
|
|
32
|
+
breadcrumb: [
|
|
33
|
+
{ label: 'Deals', labelKey: 'customers.nav.deals', href: '/backend/customers/deals' },
|
|
34
|
+
{ label: 'Deals Map', labelKey: 'customers.nav.deals.map' },
|
|
35
|
+
],
|
|
36
|
+
}
|