@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.
Files changed (69) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/attachments/lib/scope.js +26 -0
  3. package/dist/modules/attachments/lib/scope.js.map +7 -0
  4. package/dist/modules/catalog/commands/variants.js +4 -2
  5. package/dist/modules/catalog/commands/variants.js.map +2 -2
  6. package/dist/modules/customers/api/deals/map/route.js +433 -0
  7. package/dist/modules/customers/api/deals/map/route.js.map +7 -0
  8. package/dist/modules/customers/backend/customers/companies/create/page.js +2 -0
  9. package/dist/modules/customers/backend/customers/companies/create/page.js.map +2 -2
  10. package/dist/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.js +159 -0
  11. package/dist/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.js.map +7 -0
  12. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.js +26 -0
  13. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.js.map +7 -0
  14. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js +321 -0
  15. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js.map +7 -0
  16. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js +533 -0
  17. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js.map +7 -0
  18. package/dist/modules/customers/backend/customers/deals/map/page.js +55 -0
  19. package/dist/modules/customers/backend/customers/deals/map/page.js.map +7 -0
  20. package/dist/modules/customers/backend/customers/deals/map/page.meta.js +38 -0
  21. package/dist/modules/customers/backend/customers/deals/map/page.meta.js.map +7 -0
  22. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +3 -3
  23. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +2 -2
  24. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +22 -1
  25. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +2 -2
  26. package/dist/modules/customers/backend/customers/people/create/page.js +2 -0
  27. package/dist/modules/customers/backend/customers/people/create/page.js.map +2 -2
  28. package/dist/modules/customers/components/AddressEditor.js +34 -2
  29. package/dist/modules/customers/components/AddressEditor.js.map +2 -2
  30. package/dist/modules/customers/components/AddressTiles.js +43 -3
  31. package/dist/modules/customers/components/AddressTiles.js.map +2 -2
  32. package/dist/modules/customers/components/detail/AddressesSection.js +4 -1
  33. package/dist/modules/customers/components/detail/AddressesSection.js.map +2 -2
  34. package/dist/modules/customers/components/formConfig.js +10 -0
  35. package/dist/modules/customers/components/formConfig.js.map +2 -2
  36. package/dist/modules/customers/data/validators.js +3 -2
  37. package/dist/modules/customers/data/validators.js.map +2 -2
  38. package/dist/modules/customers/lib/dealsMapLocation.js +75 -0
  39. package/dist/modules/customers/lib/dealsMapLocation.js.map +7 -0
  40. package/dist/modules/messages/commands/messages.js +0 -1
  41. package/dist/modules/messages/commands/messages.js.map +2 -2
  42. package/dist/modules/messages/lib/attachments.js +13 -7
  43. package/dist/modules/messages/lib/attachments.js.map +2 -2
  44. package/package.json +11 -7
  45. package/src/modules/attachments/lib/scope.ts +42 -0
  46. package/src/modules/catalog/commands/variants.ts +7 -2
  47. package/src/modules/customers/api/deals/map/route.ts +549 -0
  48. package/src/modules/customers/backend/customers/companies/create/page.tsx +2 -0
  49. package/src/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.tsx +213 -0
  50. package/src/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.tsx +79 -0
  51. package/src/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.tsx +407 -0
  52. package/src/modules/customers/backend/customers/deals/map/components/DealsMapView.tsx +667 -0
  53. package/src/modules/customers/backend/customers/deals/map/page.meta.ts +36 -0
  54. package/src/modules/customers/backend/customers/deals/map/page.tsx +77 -0
  55. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +3 -3
  56. package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +22 -1
  57. package/src/modules/customers/backend/customers/people/create/page.tsx +2 -0
  58. package/src/modules/customers/components/AddressEditor.tsx +35 -0
  59. package/src/modules/customers/components/AddressTiles.tsx +51 -1
  60. package/src/modules/customers/components/detail/AddressesSection.tsx +3 -0
  61. package/src/modules/customers/components/formConfig.tsx +10 -0
  62. package/src/modules/customers/data/validators.ts +13 -2
  63. package/src/modules/customers/i18n/de.json +24 -0
  64. package/src/modules/customers/i18n/en.json +24 -0
  65. package/src/modules/customers/i18n/es.json +24 -0
  66. package/src/modules/customers/i18n/pl.json +24 -0
  67. package/src/modules/customers/lib/dealsMapLocation.ts +125 -0
  68. package/src/modules/messages/commands/messages.ts +0 -1
  69. 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
+ }