@open-mercato/core 0.6.3-develop.3651.1.d3a548a5f1 → 0.6.3-develop.3653.1.b8558737ae

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 (160) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/customer_settings/index.js +2 -0
  3. package/dist/generated/entities/customer_settings/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/customers/api/deals/aggregate/route.js +332 -0
  7. package/dist/modules/customers/api/deals/aggregate/route.js.map +7 -0
  8. package/dist/modules/customers/api/deals/bulk-update-owner/route.js +188 -0
  9. package/dist/modules/customers/api/deals/bulk-update-owner/route.js.map +7 -0
  10. package/dist/modules/customers/api/deals/bulk-update-stage/route.js +188 -0
  11. package/dist/modules/customers/api/deals/bulk-update-stage/route.js.map +7 -0
  12. package/dist/modules/customers/api/deals/route.js +255 -122
  13. package/dist/modules/customers/api/deals/route.js.map +2 -2
  14. package/dist/modules/customers/api/settings/stuck-threshold/route.js +169 -0
  15. package/dist/modules/customers/api/settings/stuck-threshold/route.js.map +7 -0
  16. package/dist/modules/customers/backend/config/customers/deals/page.js +181 -0
  17. package/dist/modules/customers/backend/config/customers/deals/page.js.map +7 -0
  18. package/dist/modules/customers/backend/config/customers/deals/page.meta.js +38 -0
  19. package/dist/modules/customers/backend/config/customers/deals/page.meta.js.map +7 -0
  20. package/dist/modules/customers/backend/customers/deals/create/page.js +17 -6
  21. package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
  22. package/dist/modules/customers/backend/customers/deals/page.js +2 -0
  23. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  24. package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js +217 -0
  25. package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js.map +7 -0
  26. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js +335 -0
  27. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js.map +7 -0
  28. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js +29 -0
  29. package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js.map +7 -0
  30. package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js +160 -0
  31. package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js.map +7 -0
  32. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js +157 -0
  33. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js.map +7 -0
  34. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js +114 -0
  35. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js.map +7 -0
  36. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js +36 -0
  37. package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js.map +7 -0
  38. package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js +199 -0
  39. package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js.map +7 -0
  40. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js +71 -0
  41. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js.map +7 -0
  42. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js +233 -0
  43. package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js.map +7 -0
  44. package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js +126 -0
  45. package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js.map +7 -0
  46. package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js +31 -0
  47. package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js.map +7 -0
  48. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js +389 -0
  49. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js.map +7 -0
  50. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js +181 -0
  51. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js.map +7 -0
  52. package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js +199 -0
  53. package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js.map +7 -0
  54. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +44 -0
  55. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +7 -0
  56. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js +70 -0
  57. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js.map +7 -0
  58. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +278 -0
  59. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +7 -0
  60. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js +135 -0
  61. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js.map +7 -0
  62. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js +83 -0
  63. package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js.map +7 -0
  64. package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js +155 -0
  65. package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js.map +7 -0
  66. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +318 -0
  67. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +7 -0
  68. package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js +138 -0
  69. package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js.map +7 -0
  70. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +126 -0
  71. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +7 -0
  72. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +71 -0
  73. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +7 -0
  74. package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js +7 -0
  75. package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js.map +7 -0
  76. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +2330 -415
  77. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +3 -3
  78. package/dist/modules/customers/commands/deals.js +6 -0
  79. package/dist/modules/customers/commands/deals.js.map +2 -2
  80. package/dist/modules/customers/commands/pipeline-stages.js +48 -13
  81. package/dist/modules/customers/commands/pipeline-stages.js.map +3 -3
  82. package/dist/modules/customers/commands/settings.js +45 -5
  83. package/dist/modules/customers/commands/settings.js.map +2 -2
  84. package/dist/modules/customers/data/enrichers.js +130 -1
  85. package/dist/modules/customers/data/enrichers.js.map +2 -2
  86. package/dist/modules/customers/data/entities.js +4 -0
  87. package/dist/modules/customers/data/entities.js.map +2 -2
  88. package/dist/modules/customers/data/validators.js +29 -2
  89. package/dist/modules/customers/data/validators.js.map +2 -2
  90. package/dist/modules/customers/lib/bulkDeals.js +150 -0
  91. package/dist/modules/customers/lib/bulkDeals.js.map +7 -0
  92. package/dist/modules/customers/lib/kysely.js +12 -0
  93. package/dist/modules/customers/lib/kysely.js.map +7 -0
  94. package/dist/modules/customers/lib/stuckDeals.js +32 -0
  95. package/dist/modules/customers/lib/stuckDeals.js.map +7 -0
  96. package/dist/modules/customers/migrations/Migration20260513203311_customers.js +13 -0
  97. package/dist/modules/customers/migrations/Migration20260513203311_customers.js.map +7 -0
  98. package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js +52 -0
  99. package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +7 -0
  100. package/dist/modules/customers/workers/deals-bulk-update-owner.js +41 -0
  101. package/dist/modules/customers/workers/deals-bulk-update-owner.js.map +7 -0
  102. package/dist/modules/customers/workers/deals-bulk-update-stage.js +41 -0
  103. package/dist/modules/customers/workers/deals-bulk-update-stage.js.map +7 -0
  104. package/generated/entities/customer_settings/index.ts +1 -0
  105. package/generated/entity-fields-registry.ts +1 -0
  106. package/package.json +7 -7
  107. package/src/modules/customers/api/deals/aggregate/route.ts +438 -0
  108. package/src/modules/customers/api/deals/bulk-update-owner/route.ts +216 -0
  109. package/src/modules/customers/api/deals/bulk-update-stage/route.ts +228 -0
  110. package/src/modules/customers/api/deals/route.ts +330 -135
  111. package/src/modules/customers/api/settings/stuck-threshold/route.ts +195 -0
  112. package/src/modules/customers/backend/config/customers/deals/page.meta.ts +36 -0
  113. package/src/modules/customers/backend/config/customers/deals/page.tsx +205 -0
  114. package/src/modules/customers/backend/customers/deals/create/page.tsx +25 -6
  115. package/src/modules/customers/backend/customers/deals/page.tsx +2 -0
  116. package/src/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.tsx +277 -0
  117. package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.tsx +425 -0
  118. package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.tsx +31 -0
  119. package/src/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.tsx +171 -0
  120. package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.tsx +196 -0
  121. package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.tsx +138 -0
  122. package/src/modules/customers/backend/customers/deals/pipeline/components/ChipButton.tsx +47 -0
  123. package/src/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.tsx +232 -0
  124. package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.tsx +110 -0
  125. package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.tsx +310 -0
  126. package/src/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.tsx +169 -0
  127. package/src/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.tsx +54 -0
  128. package/src/modules/customers/backend/customers/deals/pipeline/components/DealCard.tsx +562 -0
  129. package/src/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.tsx +214 -0
  130. package/src/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.tsx +257 -0
  131. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +62 -0
  132. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.tsx +101 -0
  133. package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +378 -0
  134. package/src/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.tsx +192 -0
  135. package/src/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.tsx +107 -0
  136. package/src/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.tsx +196 -0
  137. package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +408 -0
  138. package/src/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.tsx +164 -0
  139. package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +171 -0
  140. package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +84 -0
  141. package/src/modules/customers/backend/customers/deals/pipeline/components/constants.ts +16 -0
  142. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2841 -550
  143. package/src/modules/customers/commands/deals.ts +19 -0
  144. package/src/modules/customers/commands/pipeline-stages.ts +68 -13
  145. package/src/modules/customers/commands/settings.ts +64 -5
  146. package/src/modules/customers/data/enrichers.ts +209 -2
  147. package/src/modules/customers/data/entities.ts +4 -1
  148. package/src/modules/customers/data/validators.ts +36 -2
  149. package/src/modules/customers/i18n/de.json +244 -0
  150. package/src/modules/customers/i18n/en.json +244 -0
  151. package/src/modules/customers/i18n/es.json +244 -0
  152. package/src/modules/customers/i18n/pl.json +244 -0
  153. package/src/modules/customers/lib/bulkDeals.ts +231 -0
  154. package/src/modules/customers/lib/kysely.ts +60 -0
  155. package/src/modules/customers/lib/stuckDeals.ts +88 -0
  156. package/src/modules/customers/migrations/.snapshot-open-mercato.json +16 -0
  157. package/src/modules/customers/migrations/Migration20260513203311_customers.ts +13 -0
  158. package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +71 -0
  159. package/src/modules/customers/workers/deals-bulk-update-owner.ts +46 -0
  160. package/src/modules/customers/workers/deals-bulk-update-stage.ts +46 -0
@@ -2,8 +2,38 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import Link from 'next/link'
5
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
5
+ import { useRouter } from 'next/navigation'
6
+ import { useQueries, useQuery, useQueryClient } from '@tanstack/react-query'
7
+ import {
8
+ DndContext,
9
+ DragOverlay,
10
+ KeyboardSensor,
11
+ MeasuringStrategy,
12
+ PointerSensor,
13
+ pointerWithin,
14
+ useSensor,
15
+ useSensors,
16
+ type CollisionDetection,
17
+ type DragEndEvent,
18
+ type DragStartEvent,
19
+ } from '@dnd-kit/core'
20
+ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'
21
+ import { ChevronLeft, ChevronRight, Layers, Plus, SlidersHorizontal, Workflow } from 'lucide-react'
6
22
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
23
+ import {
24
+ Breadcrumb,
25
+ BreadcrumbItem,
26
+ BreadcrumbLink,
27
+ BreadcrumbList,
28
+ BreadcrumbPage,
29
+ BreadcrumbSeparator,
30
+ } from '@open-mercato/ui/primitives/breadcrumb'
31
+ import { Button } from '@open-mercato/ui/primitives/button'
32
+ import { IconButton } from '@open-mercato/ui/primitives/icon-button'
33
+ import { SearchInput } from '@open-mercato/ui/primitives/search-input'
34
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
35
+ import { ErrorNotice } from '@open-mercato/ui/primitives/ErrorNotice'
36
+ import { EmptyState } from '@open-mercato/ui/primitives/empty-state'
7
37
  import {
8
38
  Select,
9
39
  SelectContent,
@@ -11,68 +41,168 @@ import {
11
41
  SelectTrigger,
12
42
  SelectValue,
13
43
  } from '@open-mercato/ui/primitives/select'
14
- import { Layers, MoveRight, Workflow } from 'lucide-react'
15
- import { Spinner } from '@open-mercato/ui/primitives/spinner'
16
- import { ErrorNotice } from '@open-mercato/ui/primitives/ErrorNotice'
17
- import { EmptyState } from '@open-mercato/ui/primitives/empty-state'
18
- import { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
44
+ import { apiCall, apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
45
+ import { useCurrentUserId } from '@open-mercato/ui/backend/utils/useCurrentUserId'
19
46
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
47
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
48
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
49
+ import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent'
50
+ import type { RowActionItem } from '@open-mercato/ui/backend/RowActions'
20
51
  import { useT } from '@open-mercato/shared/lib/i18n/context'
21
52
  import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
22
53
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
54
+ import type { FilterOptionTone } from '@open-mercato/shared/lib/query/advanced-filter'
55
+ import { ViewTabsRow } from './components/ViewTabsRow'
56
+ import { LANE_WIDTH_CLASS } from './components/constants'
57
+ import { useCurrencyDictionary } from '../../../../components/detail/hooks/useCurrencyDictionary'
58
+ import { FilterBarRow, type KanbanFilterChip } from './components/FilterBarRow'
59
+ import { Lane, type LaneStage } from './components/Lane'
60
+ import { LaneCurrencyBreakdown } from './components/LaneCurrencyBreakdown'
61
+ import { CurrencyFilterPopover } from './components/CurrencyFilterPopover'
62
+ import { AddStageLane } from './components/AddStageLane'
63
+ import type { DealCardData } from './components/DealCard'
64
+ import {
65
+ QuickDealDialog,
66
+ type QuickDealContext,
67
+ type QuickDealCompanyOption,
68
+ } from './components/QuickDealDialog'
69
+ import { AddStageDialog, type AddStageContext } from './components/AddStageDialog'
70
+ import { StatusFilterPopover } from './components/StatusFilterPopover'
71
+ import { PipelineFilterPopover } from './components/PipelineFilterPopover'
72
+ import { SortByPopover, type SortOption } from './components/SortByPopover'
73
+ import { EntityFilterPopover, type EntityFilterOption } from './components/EntityFilterPopover'
74
+ import { CloseDateFilterPopover, type CloseDateRange } from './components/CloseDateFilterPopover'
75
+ import { CustomizeViewDialog } from './components/CustomizeViewDialog'
76
+ import {
77
+ ActivityComposerDialog,
78
+ type ActivityComposerContext,
79
+ } from './components/ActivityComposerDialog'
80
+ import { BulkActionsBar } from './components/BulkActionsBar'
81
+ import { ChangeStageDialog } from './components/ChangeStageDialog'
82
+ import { ChangeOwnerDialog } from './components/ChangeOwnerDialog'
83
+ import { buildCrudExportUrl, deleteCrud } from '@open-mercato/ui/backend/utils/crud'
84
+ import { runBulkDelete, groupBulkDeleteFailures } from '@open-mercato/ui/backend/utils/bulkDelete'
85
+ import {
86
+ fetchAssignableStaffMembers,
87
+ type AssignableStaffMember,
88
+ } from '../../../../components/detail/assignableStaff'
23
89
 
24
- type DealAssociation = { id: string; label: string }
90
+ type PipelineRecord = { id: string; name: string; isDefault: boolean }
25
91
 
26
- type DealRecord = {
27
- id: string
28
- title: string
29
- status: string | null
30
- pipelineStage: string | null
31
- pipelineId: string | null
32
- pipelineStageId: string | null
33
- valueAmount: number | null
34
- valueCurrency: string | null
35
- probability: number | null
36
- expectedCloseAt: string | null
37
- expectedCloseAtTs: number | null
38
- createdAt: string | null
39
- createdAtTs: number | null
40
- updatedAt: string | null
41
- people: DealAssociation[]
42
- companies: DealAssociation[]
92
+ type StageAggregateRow = {
93
+ stageId: string
94
+ count: number
95
+ openCount: number
96
+ totalInBaseCurrency: number
97
+ byCurrency: Array<{ currency: string; total: number; count: number }>
98
+ convertedAll?: boolean
99
+ missingRateCurrencies?: string[]
43
100
  }
44
101
 
45
- type DealsQueryData = {
46
- deals: DealRecord[]
47
- total: number
102
+ type StageAggregateResponse = {
103
+ baseCurrencyCode: string | null
104
+ perStage: StageAggregateRow[]
48
105
  }
49
106
 
50
- type StageDefinition = {
107
+ type BulkJobResponse = {
108
+ ok: boolean
109
+ progressJobId: string | null
110
+ message?: string
111
+ }
112
+
113
+ export type LaneAggregate = {
114
+ count: number
115
+ totalInBaseCurrency: number
116
+ byCurrency: Array<{ currency: string; total: number; count: number }>
117
+ baseCurrencyCode: string | null
118
+ // Both fields default to "best case" so an older aggregate response (without these
119
+ // fields) doesn't trigger spurious "partial conversion" warnings in the UI.
120
+ convertedAll: boolean
121
+ missingRateCurrencies: string[]
122
+ }
123
+ type PipelineStageRecord = {
51
124
  id: string
52
- value: string | null
53
125
  label: string
54
- color: string | null
55
- icon: string | null
126
+ order: number
127
+ pipelineId: string
128
+ // `color` is sourced from the `customer_dictionary_entries` row that backs each stage
129
+ // (kind = 'pipeline_stage'). After the 2026-05-19 hex→tone migration this stores a
130
+ // canonical tone identifier ('success', 'warning', etc.) rather than a hex string.
131
+ color?: string | null
56
132
  }
57
133
 
58
- type SortOption = 'probability' | 'createdAt' | 'expectedCloseAt'
134
+ type DealApiRecord = Record<string, unknown> & {
135
+ id: string
136
+ title?: string | null
137
+ status?: string | null
138
+ pipeline_stage?: string | null
139
+ pipeline_id?: string | null
140
+ pipeline_stage_id?: string | null
141
+ value_amount?: number | string | null
142
+ value_currency?: string | null
143
+ probability?: number | string | null
144
+ expected_close_at?: string | null
145
+ created_at?: string | null
146
+ updated_at?: string | null
147
+ owner_user_id?: string | null
148
+ companies?: Array<{ id?: unknown; label?: unknown } | null> | null
149
+ people?: Array<{ id?: unknown; label?: unknown } | null> | null
150
+ _pipeline?: {
151
+ openActivitiesCount?: number
152
+ daysInCurrentStage?: number
153
+ isStuck?: boolean
154
+ isOverdue?: boolean
155
+ } | null
156
+ }
59
157
 
60
- type PipelineRecord = { id: string; name: string; isDefault: boolean }
61
- type PipelineStageRecord = { id: string; label: string; order: number; pipelineId: string }
158
+ const DEFAULT_SORT: SortOption = 'updated_desc'
159
+ const LANE_PAGE_SIZE = 25
160
+ const FALLBACK_TONES: FilterOptionTone[] = ['success', 'warning', 'error', 'info', 'neutral', 'brand']
161
+ // Module-level singleton so empty lanes get the same array reference every render —
162
+ // React.memo's identity check on the `deals` prop holds across renders.
163
+ const EMPTY_DEAL_ARRAY: DealCardData[] = []
62
164
 
63
- const DEALS_QUERY_LIMIT = 100
165
+ // Lane width / resize configuration (~20% larger than original Figma spec for readability)
166
+ const DEFAULT_LANE_WIDTH = 308
167
+ const MIN_LANE_WIDTH = 240
168
+ const MAX_LANE_WIDTH = 576
169
+ const LANE_GAP = 14
170
+ const LANE_WIDTHS_STORAGE_KEY_PREFIX = 'kanban-lane-widths-v2'
64
171
 
65
- const dealsQueryKey = (scopeVersion: number, pipelineId: string | null) =>
66
- ['customers', 'deals', 'pipeline', `scope:${scopeVersion}`, `pipeline:${pipelineId ?? 'none'}`] as const
172
+ function loadLaneWidths(scopeKey: string): Record<string, number> {
173
+ if (typeof window === 'undefined') return {}
174
+ try {
175
+ const raw = window.localStorage.getItem(`${LANE_WIDTHS_STORAGE_KEY_PREFIX}:${scopeKey}`)
176
+ if (!raw) return {}
177
+ const parsed = JSON.parse(raw)
178
+ if (!parsed || typeof parsed !== 'object') return {}
179
+ const out: Record<string, number> = {}
180
+ for (const [k, v] of Object.entries(parsed)) {
181
+ if (typeof v === 'number' && Number.isFinite(v) && v >= MIN_LANE_WIDTH && v <= MAX_LANE_WIDTH) {
182
+ out[k] = v
183
+ }
184
+ }
185
+ return out
186
+ } catch {
187
+ return {}
188
+ }
189
+ }
67
190
 
68
- const sortOptions: SortOption[] = ['probability', 'createdAt', 'expectedCloseAt']
191
+ function saveLaneWidths(scopeKey: string, widths: Record<string, number>) {
192
+ if (typeof window === 'undefined') return
193
+ try {
194
+ if (Object.keys(widths).length === 0) {
195
+ window.localStorage.removeItem(`${LANE_WIDTHS_STORAGE_KEY_PREFIX}:${scopeKey}`)
196
+ } else {
197
+ window.localStorage.setItem(`${LANE_WIDTHS_STORAGE_KEY_PREFIX}:${scopeKey}`, JSON.stringify(widths))
198
+ }
199
+ } catch {}
200
+ }
69
201
 
70
202
  function normalizeAmount(value: unknown): number | null {
71
203
  if (typeof value === 'number' && Number.isFinite(value)) return value
72
- if (typeof value === 'string') {
73
- const trimmed = value.trim()
74
- if (!trimmed.length) return null
75
- const parsed = Number(trimmed)
204
+ if (typeof value === 'string' && value.trim().length) {
205
+ const parsed = Number(value)
76
206
  return Number.isFinite(parsed) ? parsed : null
77
207
  }
78
208
  return null
@@ -81,133 +211,241 @@ function normalizeAmount(value: unknown): number | null {
81
211
  function normalizeProbability(value: unknown): number | null {
82
212
  const parsed = normalizeAmount(value)
83
213
  if (parsed === null) return null
84
- if (parsed < 0) return 0
85
- if (parsed > 100) return 100
86
- return Math.round(parsed)
214
+ return Math.min(Math.max(Math.round(parsed), 0), 100)
87
215
  }
88
216
 
89
- function normalizeTimestamp(value: unknown): { iso: string | null; ts: number | null } {
90
- if (typeof value !== 'string' || !value.trim().length) return { iso: null, ts: null }
217
+ function normalizeIso(value: unknown): string | null {
218
+ if (typeof value !== 'string' || !value.trim().length) return null
91
219
  const date = new Date(value)
92
- if (Number.isNaN(date.getTime())) return { iso: null, ts: null }
93
- return { iso: date.toISOString(), ts: date.getTime() }
220
+ return Number.isNaN(date.getTime()) ? null : date.toISOString()
94
221
  }
95
222
 
96
- function buildStageDefinitionsFromPipelineStages(
97
- pipelineStages: PipelineStageRecord[],
98
- deals: DealRecord[],
99
- t: ReturnType<typeof useT>,
100
- ): StageDefinition[] {
101
- const result: StageDefinition[] = pipelineStages
102
- .slice()
103
- .sort((a, b) => a.order - b.order)
104
- .map((stage) => ({
105
- id: stage.id,
106
- value: stage.id,
107
- label: stage.label,
108
- color: null,
109
- icon: null,
110
- }))
111
-
112
- const knownIds = new Set(pipelineStages.map((s) => s.id))
113
- const hasUnassigned = deals.some((deal) => !deal.pipelineStageId || !knownIds.has(deal.pipelineStageId))
114
- if (hasUnassigned) {
115
- result.push({
116
- id: 'stage:__unassigned',
117
- value: null,
118
- label: translateWithFallback(t, 'customers.deals.pipeline.unassigned', 'No stage'),
119
- color: null,
120
- icon: null,
121
- })
122
- }
223
+ function normalizeAssociation(entry: unknown): { id: string; label: string } | null {
224
+ if (!entry || typeof entry !== 'object') return null
225
+ const ref = entry as Record<string, unknown>
226
+ const id = typeof ref.id === 'string' ? ref.id : null
227
+ if (!id) return null
228
+ const label =
229
+ typeof ref.label === 'string' && ref.label.trim().length ? ref.label.trim() : id
230
+ return { id, label }
231
+ }
123
232
 
124
- return result
233
+ function mapDealRecord(item: DealApiRecord, fallbackTitle: string): DealCardData | null {
234
+ const id = typeof item.id === 'string' ? item.id : null
235
+ if (!id) return null
236
+ const title =
237
+ typeof item.title === 'string' && item.title.trim().length ? item.title.trim() : fallbackTitle
238
+ const status =
239
+ typeof item.status === 'string' && item.status.trim().length ? item.status.trim() : null
240
+ const valueAmount = normalizeAmount(item.value_amount)
241
+ const valueCurrency =
242
+ typeof item.value_currency === 'string' && item.value_currency.trim().length
243
+ ? item.value_currency.trim().toUpperCase()
244
+ : null
245
+ const probability = normalizeProbability(item.probability)
246
+ const expectedCloseAt = normalizeIso(item.expected_close_at)
247
+ const createdAt = normalizeIso(item.created_at)
248
+ const updatedAt = normalizeIso(item.updated_at)
249
+ const ownerUserId =
250
+ typeof item.owner_user_id === 'string' && item.owner_user_id.trim().length
251
+ ? item.owner_user_id
252
+ : null
253
+ const companies = Array.isArray(item.companies)
254
+ ? (item.companies.map(normalizeAssociation).filter(Boolean) as { id: string; label: string }[])
255
+ : []
256
+ const primaryCompany = companies[0] ?? null
257
+ const pipelineState = {
258
+ openActivitiesCount:
259
+ typeof item._pipeline?.openActivitiesCount === 'number' ? item._pipeline.openActivitiesCount : 0,
260
+ daysInCurrentStage:
261
+ typeof item._pipeline?.daysInCurrentStage === 'number' ? item._pipeline.daysInCurrentStage : 0,
262
+ isStuck: !!item._pipeline?.isStuck,
263
+ isOverdue: !!item._pipeline?.isOverdue,
264
+ }
265
+ return {
266
+ id,
267
+ title,
268
+ status,
269
+ valueAmount,
270
+ valueCurrency,
271
+ probability,
272
+ expectedCloseAt,
273
+ createdAt,
274
+ updatedAt,
275
+ owner: ownerUserId ? { userId: ownerUserId, label: '' } : null,
276
+ primaryCompany,
277
+ pipelineState,
278
+ }
125
279
  }
126
280
 
127
- function createDealMap(deals: DealRecord[]): Map<string, DealRecord> {
128
- return deals.reduce<Map<string, DealRecord>>((acc, deal) => acc.set(deal.id, deal), new Map())
281
+ // Canonical tone identifiers stored in `customer_dictionary_entries.color` after the
282
+ // 2026-05-19 hex→tone migration. Keep in sync with `TONE_OPTIONS` in `AddStageDialog.tsx`.
283
+ const KNOWN_STAGE_TONES: ReadonlySet<string> = new Set([
284
+ 'success',
285
+ 'warning',
286
+ 'info',
287
+ 'error',
288
+ 'neutral',
289
+ 'brand',
290
+ 'pink',
291
+ ])
292
+
293
+ function buildLaneStages(
294
+ stages: PipelineStageRecord[],
295
+ unassignedLabel: string,
296
+ hasUnassigned: boolean,
297
+ ): LaneStage[] {
298
+ const sorted = stages.slice().sort((a, b) => a.order - b.order)
299
+ const lanes: LaneStage[] = sorted.map((stage, index) => ({
300
+ id: stage.id,
301
+ label: stage.label,
302
+ // Prefer the stage's persisted tone (now stored as a canonical identifier post-migration)
303
+ // so colors picked by operators in AddStageDialog actually show up on the kanban. Falls
304
+ // back to the position-based rotation for stages with no color set or unmapped legacy
305
+ // values that survived the migration's neutral-fallback arm.
306
+ tone:
307
+ stage.color && KNOWN_STAGE_TONES.has(stage.color)
308
+ ? (stage.color as FilterOptionTone)
309
+ : FALLBACK_TONES[index % FALLBACK_TONES.length],
310
+ }))
311
+ if (hasUnassigned) {
312
+ lanes.push({ id: '__unassigned', label: unassignedLabel, tone: 'neutral' })
313
+ }
314
+ return lanes
129
315
  }
130
316
 
131
- function groupDealsByStageId(deals: DealRecord[]): Map<string | null, DealRecord[]> {
132
- const byStage = new Map<string | null, DealRecord[]>()
133
- deals.forEach((deal) => {
134
- const stageKey = deal.pipelineStageId ?? null
135
- const bucket = byStage.get(stageKey) ?? []
317
+ function groupDealsByStageId(deals: DealCardData[], stageIdByDealId: Map<string, string>): Map<string, DealCardData[]> {
318
+ const grouped = new Map<string, DealCardData[]>()
319
+ for (const deal of deals) {
320
+ const stageKey = stageIdByDealId.get(deal.id) ?? '__unassigned'
321
+ const bucket = grouped.get(stageKey) ?? []
136
322
  bucket.push(deal)
137
- byStage.set(stageKey, bucket)
138
- })
139
- return byStage
323
+ grouped.set(stageKey, bucket)
324
+ }
325
+ return grouped
326
+ }
327
+
328
+ /**
329
+ * Translate the UI SortOption into the deals CRUD API's `sortField` / `sortDir` query params.
330
+ *
331
+ * Returns `null` for `owner_asc` because the deals table only stores `owner_user_id` (a UUID);
332
+ * the UI displays a resolved owner name, so a UUID-alphabetical server sort would be misleading.
333
+ * For that case the caller falls back to a sensible default sort server-side and re-sorts the
334
+ * page client-side via `sortDeals`. All other options sort server-side over the full result set,
335
+ * so paging (25/lane → Show more) keeps a globally correct order.
336
+ */
337
+ function mapSortOptionToApi(option: SortOption): { sortField: string; sortDir: 'asc' | 'desc' } | null {
338
+ switch (option) {
339
+ case 'updated_desc':
340
+ return { sortField: 'updatedAt', sortDir: 'desc' }
341
+ case 'updated_asc':
342
+ return { sortField: 'updatedAt', sortDir: 'asc' }
343
+ case 'created_desc':
344
+ return { sortField: 'createdAt', sortDir: 'desc' }
345
+ case 'value_desc':
346
+ return { sortField: 'value', sortDir: 'desc' }
347
+ case 'value_asc':
348
+ return { sortField: 'value', sortDir: 'asc' }
349
+ case 'probability_desc':
350
+ return { sortField: 'probability', sortDir: 'desc' }
351
+ case 'close_asc':
352
+ return { sortField: 'expectedCloseAt', sortDir: 'asc' }
353
+ case 'owner_asc':
354
+ default:
355
+ return null
356
+ }
140
357
  }
141
358
 
142
- function sortDeals(deals: DealRecord[], option: SortOption): DealRecord[] {
143
- const sorted = [...deals]
359
+ function sortDeals(deals: DealCardData[], option: SortOption): DealCardData[] {
360
+ const sorted = deals.slice()
144
361
  sorted.sort((a, b) => {
145
- if (option === 'probability') {
146
- const ap = typeof a.probability === 'number' ? a.probability : -1
147
- const bp = typeof b.probability === 'number' ? b.probability : -1
148
- if (ap !== bp) return bp - ap
149
- }
150
- if (option === 'expectedCloseAt') {
151
- const at = typeof a.expectedCloseAtTs === 'number' ? a.expectedCloseAtTs : Number.POSITIVE_INFINITY
152
- const bt = typeof b.expectedCloseAtTs === 'number' ? b.expectedCloseAtTs : Number.POSITIVE_INFINITY
153
- if (at !== bt) return at - bt
154
- }
155
- const at = typeof a.createdAtTs === 'number' ? a.createdAtTs : Number.NEGATIVE_INFINITY
156
- const bt = typeof b.createdAtTs === 'number' ? b.createdAtTs : Number.NEGATIVE_INFINITY
157
- if (option === 'createdAt') {
158
- if (at !== bt) return bt - at
159
- } else if (option === 'expectedCloseAt' || option === 'probability') {
160
- if (at !== bt) return bt - at
362
+ switch (option) {
363
+ case 'value_desc':
364
+ case 'value_asc': {
365
+ const aValue = typeof a.valueAmount === 'number' ? a.valueAmount : Number.NEGATIVE_INFINITY
366
+ const bValue = typeof b.valueAmount === 'number' ? b.valueAmount : Number.NEGATIVE_INFINITY
367
+ return option === 'value_desc' ? bValue - aValue : aValue - bValue
368
+ }
369
+ case 'probability_desc': {
370
+ const aProb = typeof a.probability === 'number' ? a.probability : -1
371
+ const bProb = typeof b.probability === 'number' ? b.probability : -1
372
+ return bProb - aProb
373
+ }
374
+ case 'close_asc': {
375
+ const aTs = a.expectedCloseAt ? new Date(a.expectedCloseAt).getTime() : Number.POSITIVE_INFINITY
376
+ const bTs = b.expectedCloseAt ? new Date(b.expectedCloseAt).getTime() : Number.POSITIVE_INFINITY
377
+ return aTs - bTs
378
+ }
379
+ case 'created_desc': {
380
+ const aTs = a.createdAt ? new Date(a.createdAt).getTime() : 0
381
+ const bTs = b.createdAt ? new Date(b.createdAt).getTime() : 0
382
+ return bTs - aTs
383
+ }
384
+ case 'owner_asc': {
385
+ // Owner sort is client-side only: deals carry `owner.userId` (UUID), and the human-readable
386
+ // label is resolved separately via ownerNamesById. Compare on the resolved label that was
387
+ // already merged into deal.owner.label by `dealsByStage`'s ownerNamesById pass.
388
+ const aLabel = a.owner?.label ?? a.owner?.userId ?? ''
389
+ const bLabel = b.owner?.label ?? b.owner?.userId ?? ''
390
+ return aLabel.localeCompare(bLabel)
391
+ }
392
+ case 'updated_asc':
393
+ return (a.updatedAt ?? a.createdAt ?? '').localeCompare(b.updatedAt ?? b.createdAt ?? '')
394
+ case 'updated_desc':
395
+ default:
396
+ return (b.updatedAt ?? b.createdAt ?? '').localeCompare(a.updatedAt ?? a.createdAt ?? '')
161
397
  }
162
- return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })
163
398
  })
164
399
  return sorted
165
400
  }
166
401
 
167
- function formatCurrency(amount: number | null, currency: string | null, fallback: string): string {
168
- if (amount === null || Number.isNaN(amount)) return fallback
169
- const code = currency && currency.length === 3 ? currency.toUpperCase() : 'USD'
170
- try {
171
- return new Intl.NumberFormat(undefined, {
172
- style: 'currency',
173
- currency: code,
174
- maximumFractionDigits: 2,
175
- }).format(amount)
176
- } catch {
177
- return `${code} ${amount.toFixed(2)}`
178
- }
179
- }
180
-
181
- function formatProbability(probability: number | null, fallback: string): string {
182
- if (typeof probability !== 'number' || Number.isNaN(probability)) return fallback
183
- return `${probability}%`
184
- }
185
-
186
- export default function SalesPipelinePage(): React.ReactElement {
402
+ export default function DealsKanbanPage(): React.ReactElement {
187
403
  const t = useT()
188
- const translate = React.useCallback(
189
- (key: string, fallback: string, params?: Record<string, string | number>) => {
190
- const value = translateWithFallback(t, key, fallback, params)
191
- if (value === fallback && params) {
192
- return fallback.replace(/\{\{(\w+)\}\}|\{(\w+)\}/g, (match, doubleToken, singleToken) => {
193
- const token = (doubleToken ?? singleToken) as string | undefined
194
- if (!token) return match
195
- const replacement = params[token]
196
- if (replacement === undefined) {
197
- return doubleToken ? `{{${token}}}` : `{${token}}`
198
- }
199
- return String(replacement)
200
- })
201
- }
202
- return value
203
- },
204
- [t],
205
- )
404
+ const router = useRouter()
206
405
  const scopeVersion = useOrganizationScopeVersion()
207
406
  const queryClient = useQueryClient()
208
- const [sortBy, setSortBy] = React.useState<SortOption>('probability')
209
- const [pendingDealId, setPendingDealId] = React.useState<string | null>(null)
407
+
210
408
  const [selectedPipelineId, setSelectedPipelineId] = React.useState<string | null>(null)
409
+ const [search, setSearch] = React.useState('')
410
+ const [sortBy, setSortBy] = React.useState<SortOption>(DEFAULT_SORT)
411
+ const [statusFilters, setStatusFilters] = React.useState<string[]>([])
412
+ const [ownerFilters, setOwnerFilters] = React.useState<string[]>([])
413
+ const [peopleFilters, setPeopleFilters] = React.useState<string[]>([])
414
+ const [companyFilters, setCompanyFilters] = React.useState<string[]>([])
415
+ const [closeDateFilter, setCloseDateFilter] = React.useState<CloseDateRange>({ from: null, to: null })
416
+ // Currency filter narrows visible deal cards (list query) but DOES NOT narrow the aggregate
417
+ // query — lane headers + the Currency popover keep the full breakdown so the operator can
418
+ // see what they're filtering for and change selection without losing context. `null` = "All
419
+ // currencies" sentinel from the Figma popover (1251:699).
420
+ const [currencyFilter, setCurrencyFilter] = React.useState<string | null>(null)
421
+ // Cache labels for selected ids so the chip can show readable text without re-fetching
422
+ const [ownerLabels, setOwnerLabels] = React.useState<Record<string, string>>({})
423
+ const [peopleLabels, setPeopleLabels] = React.useState<Record<string, string>>({})
424
+ const [companyLabels, setCompanyLabels] = React.useState<Record<string, string>>({})
425
+ const [selectedDealIds, setSelectedDealIds] = React.useState<Set<string>>(new Set())
426
+ const [pendingDealId, setPendingDealId] = React.useState<string | null>(null)
427
+ const [quickDealContext, setQuickDealContext] = React.useState<QuickDealContext | null>(null)
428
+ const [addStageContext, setAddStageContext] = React.useState<AddStageContext | null>(null)
429
+ const [customizeOpen, setCustomizeOpen] = React.useState(false)
430
+ const [activityContext, setActivityContext] = React.useState<ActivityComposerContext | null>(null)
431
+ const [changeStageOpen, setChangeStageOpen] = React.useState(false)
432
+ const [changeOwnerOpen, setChangeOwnerOpen] = React.useState(false)
433
+ const [isBulkMutating, setIsBulkMutating] = React.useState(false)
434
+ const [singleMoveStageContext, setSingleMoveStageContext] = React.useState<
435
+ { dealId: string; currentStageId: string | null } | null
436
+ >(null)
437
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
438
+
439
+ const fallbackTitle = translateWithFallback(
440
+ t,
441
+ 'customers.deals.pipeline.untitled',
442
+ 'Untitled deal',
443
+ )
444
+ const unassignedLabel = translateWithFallback(
445
+ t,
446
+ 'customers.deals.pipeline.unassigned',
447
+ 'No stage',
448
+ )
211
449
 
212
450
  const pipelinesQuery = useQuery<PipelineRecord[]>({
213
451
  queryKey: ['customers', 'pipelines', `scope:${scopeVersion}`],
@@ -216,7 +454,13 @@ export default function SalesPipelinePage(): React.ReactElement {
216
454
  const payload = await readApiResultOrThrow<{ items: PipelineRecord[] }>(
217
455
  '/api/customers/pipelines',
218
456
  undefined,
219
- { errorMessage: translate('customers.deals.pipeline.loadError', 'Failed to load pipelines.') },
457
+ {
458
+ errorMessage: translateWithFallback(
459
+ t,
460
+ 'customers.deals.pipeline.loadError',
461
+ 'Failed to load pipelines.',
462
+ ),
463
+ },
220
464
  )
221
465
  return payload?.items ?? []
222
466
  },
@@ -226,484 +470,2531 @@ export default function SalesPipelinePage(): React.ReactElement {
226
470
  if (selectedPipelineId) return
227
471
  const pipelines = pipelinesQuery.data
228
472
  if (!pipelines || !pipelines.length) return
229
- const defaultPipeline = pipelines.find((p) => p.isDefault) ?? pipelines[0]
473
+ const defaultPipeline = pipelines.find((pipeline) => pipeline.isDefault) ?? pipelines[0]
230
474
  if (defaultPipeline) setSelectedPipelineId(defaultPipeline.id)
231
475
  }, [pipelinesQuery.data, selectedPipelineId])
232
476
 
477
+ const staffQuery = useQuery<AssignableStaffMember[]>({
478
+ queryKey: ['customers', 'deals', 'kanban', 'staff', `scope:${scopeVersion}`],
479
+ staleTime: 300_000,
480
+ queryFn: async () => fetchAssignableStaffMembers('', { pageSize: 100 }),
481
+ })
482
+
483
+ const ownerNamesById = React.useMemo(() => {
484
+ const map = new Map<string, string>()
485
+ for (const member of staffQuery.data ?? []) {
486
+ if (member.userId && member.displayName) map.set(member.userId, member.displayName)
487
+ }
488
+ return map
489
+ }, [staffQuery.data])
490
+
491
+ const currentUserId = useCurrentUserId()
492
+ const currentUserLabel = React.useMemo(() => {
493
+ if (!currentUserId) return undefined
494
+ return ownerNamesById.get(currentUserId)
495
+ }, [currentUserId, ownerNamesById])
496
+
497
+ // Per-user, per-pipeline persisted lane widths. localStorage is per-browser per-user — exactly
498
+ // matches the "remember my preferred column widths" UX from Monday / Asana / ClickUp.
499
+ const laneWidthsScopeKey = React.useMemo(
500
+ () => `${currentUserId ?? 'anon'}:${selectedPipelineId ?? 'none'}`,
501
+ [currentUserId, selectedPipelineId],
502
+ )
503
+ const [laneWidths, setLaneWidths] = React.useState<Record<string, number>>({})
504
+ // Hydrate from localStorage when the scope (user / pipeline) changes
505
+ React.useEffect(() => {
506
+ setLaneWidths(loadLaneWidths(laneWidthsScopeKey))
507
+ }, [laneWidthsScopeKey])
508
+ // Persist on change (debounced via setTimeout to coalesce drag-frame updates)
509
+ React.useEffect(() => {
510
+ const handle = window.setTimeout(() => saveLaneWidths(laneWidthsScopeKey, laneWidths), 250)
511
+ return () => window.clearTimeout(handle)
512
+ }, [laneWidthsScopeKey, laneWidths])
513
+
514
+ const handleLaneResize = React.useCallback(
515
+ (stageId: string, deltaPx: number) => {
516
+ setLaneWidths((prev) => {
517
+ const current = prev[stageId] ?? DEFAULT_LANE_WIDTH
518
+ const next = Math.max(MIN_LANE_WIDTH, Math.min(MAX_LANE_WIDTH, current + deltaPx))
519
+ if (next === current) return prev
520
+ return { ...prev, [stageId]: next }
521
+ })
522
+ },
523
+ [],
524
+ )
525
+ const handleResetLaneWidth = React.useCallback((stageId: string) => {
526
+ setLaneWidths((prev) => {
527
+ if (!(stageId in prev)) return prev
528
+ const { [stageId]: _omit, ...rest } = prev
529
+ return rest
530
+ })
531
+ }, [])
532
+ const handleResetAllLaneWidths = React.useCallback(() => {
533
+ setLaneWidths({})
534
+ }, [])
535
+
536
+ // Tenant currency list for the Quick deal dialog. The currencies module is the source
537
+ // of truth (it backs `LaneAggregateProp.baseCurrencyCode` already); we used to hardcode
538
+ // `['PLN','EUR','USD','GBP']` in the dialog which excluded any tenant operating in CZK,
539
+ // SEK, HUF, BRL, etc. from quick-add.
540
+ const currencyDictionary = useCurrencyDictionary()
541
+
542
+ const companiesQuery = useQuery<QuickDealCompanyOption[]>({
543
+ queryKey: ['customers', 'companies', 'kanban-quick-deal', `scope:${scopeVersion}`],
544
+ staleTime: 300_000,
545
+ queryFn: async () => {
546
+ const call = await apiCall<{ items?: Array<{ id?: unknown; display_name?: unknown }> }>(
547
+ '/api/customers/companies?page=1&pageSize=100&sortField=display_name&sortDir=asc',
548
+ )
549
+ if (!call.ok) return []
550
+ const items = Array.isArray(call.result?.items) ? call.result!.items! : []
551
+ const options: QuickDealCompanyOption[] = []
552
+ for (const item of items) {
553
+ const id = typeof item.id === 'string' ? item.id : null
554
+ const displayName =
555
+ typeof item.display_name === 'string' && item.display_name.trim().length
556
+ ? item.display_name.trim()
557
+ : null
558
+ if (id && displayName) options.push({ id, label: displayName })
559
+ }
560
+ return options
561
+ },
562
+ })
563
+
233
564
  const stagesQuery = useQuery<PipelineStageRecord[]>({
234
565
  queryKey: ['customers', 'pipeline-stages', `scope:${scopeVersion}`, `pipeline:${selectedPipelineId}`],
235
566
  enabled: !!selectedPipelineId,
236
567
  staleTime: 30_000,
237
568
  queryFn: async () => {
569
+ if (!selectedPipelineId) return []
238
570
  const payload = await readApiResultOrThrow<{ items: PipelineStageRecord[] }>(
239
- `/api/customers/pipeline-stages?pipelineId=${encodeURIComponent(selectedPipelineId!)}`,
571
+ `/api/customers/pipeline-stages?pipelineId=${encodeURIComponent(selectedPipelineId)}`,
240
572
  undefined,
241
- { errorMessage: translate('customers.deals.pipeline.loadError', 'Failed to load stages.') },
573
+ {
574
+ errorMessage: translateWithFallback(
575
+ t,
576
+ 'customers.deals.pipeline.loadError',
577
+ 'Failed to load stages.',
578
+ ),
579
+ },
242
580
  )
243
581
  return payload?.items ?? []
244
582
  },
245
583
  })
246
584
 
247
- const dealsKey = React.useMemo(() => dealsQueryKey(scopeVersion, selectedPipelineId), [scopeVersion, selectedPipelineId])
585
+ // Per-lane "Show more" state. Each lane's first 25 are loaded via useQueries; subsequent pages are
586
+ // fetched on demand and appended here, so the cached page-1 cards stay in place (no full lane refetch).
587
+ const [extraCardsByStage, setExtraCardsByStage] = React.useState<Record<string, DealApiRecord[]>>({})
588
+ const [loadingMoreByStage, setLoadingMoreByStage] = React.useState<Record<string, boolean>>({})
589
+ // Reset extra-cards + loading state when any filter changes
590
+ React.useEffect(() => {
591
+ setExtraCardsByStage({})
592
+ setLoadingMoreByStage({})
593
+ }, [selectedPipelineId, search, statusFilters, ownerFilters, peopleFilters, companyFilters, closeDateFilter, currencyFilter])
248
594
 
249
- const dealsQuery = useQuery<DealsQueryData>({
250
- queryKey: dealsKey,
595
+ // Filter signature shared by every per-lane query so all lanes invalidate together when filters change
596
+ const filterSignature = React.useMemo(
597
+ () => ({
598
+ pipelineId: selectedPipelineId,
599
+ search: search.trim(),
600
+ status: statusFilters.slice().sort().join(','),
601
+ owners: ownerFilters.slice().sort().join(','),
602
+ people: peopleFilters.slice().sort().join(','),
603
+ companies: companyFilters.slice().sort().join(','),
604
+ closeFrom: closeDateFilter.from ?? '',
605
+ closeTo: closeDateFilter.to ?? '',
606
+ currency: currencyFilter ?? '',
607
+ }),
608
+ [selectedPipelineId, search, statusFilters, ownerFilters, peopleFilters, companyFilters, closeDateFilter, currencyFilter],
609
+ )
610
+
611
+ // Aggregate query — gives accurate per-stage counts + totals (in base currency) across the entire pipeline,
612
+ // not just the loaded slice. Drives both lane headers and pagination decisions.
613
+ const aggregateQuery = useQuery<StageAggregateResponse | null>({
614
+ queryKey: [
615
+ 'customers',
616
+ 'deals',
617
+ 'kanban-aggregate',
618
+ `scope:${scopeVersion}`,
619
+ `pipeline:${selectedPipelineId ?? 'none'}`,
620
+ `search:${filterSignature.search}`,
621
+ `status:${filterSignature.status}`,
622
+ `owners:${filterSignature.owners}`,
623
+ `people:${filterSignature.people}`,
624
+ `companies:${filterSignature.companies}`,
625
+ `close:${filterSignature.closeFrom}-${filterSignature.closeTo}`,
626
+ ],
251
627
  enabled: !!selectedPipelineId,
252
628
  staleTime: 30_000,
253
629
  queryFn: async () => {
254
- const search = new URLSearchParams()
255
- search.set('page', '1')
256
- search.set('pageSize', String(DEALS_QUERY_LIMIT))
257
- search.set('sortField', 'createdAt')
258
- search.set('sortDir', 'desc')
259
- if (selectedPipelineId) search.set('pipelineId', selectedPipelineId)
260
- const payload = await readApiResultOrThrow<Record<string, unknown>>(
261
- `/api/customers/deals?${search.toString()}`,
262
- undefined,
263
- { errorMessage: translate('customers.deals.pipeline.loadError', 'Failed to load deals.') },
630
+ if (!selectedPipelineId) return null
631
+ const params = new URLSearchParams()
632
+ params.set('pipelineId', selectedPipelineId)
633
+ if (filterSignature.search.length) params.set('search', filterSignature.search)
634
+ for (const status of statusFilters) params.append('status', status)
635
+ for (const ownerId of ownerFilters) params.append('ownerUserId', ownerId)
636
+ for (const personId of peopleFilters) params.append('personId', personId)
637
+ for (const companyId of companyFilters) params.append('companyId', companyId)
638
+ if (closeDateFilter.from) params.set('expectedCloseAtFrom', closeDateFilter.from)
639
+ if (closeDateFilter.to) params.set('expectedCloseAtTo', closeDateFilter.to)
640
+ const call = await apiCall<StageAggregateResponse>(
641
+ `/api/customers/deals/aggregate?${params.toString()}`,
264
642
  )
265
- const items = Array.isArray(payload?.items) ? payload.items : []
266
- const deals: DealRecord[] = []
267
- items.forEach((item) => {
268
- if (!item || typeof item !== 'object') return
269
- const data = item as Record<string, unknown>
270
- const id = typeof data.id === 'string' ? data.id : null
271
- if (!id) return
272
- const title =
273
- typeof data.title === 'string' && data.title.trim().length
274
- ? data.title.trim()
275
- : translate('customers.deals.pipeline.untitled', 'Untitled deal')
276
- const status =
277
- typeof data.status === 'string' && data.status.trim().length ? data.status.trim() : null
278
- const stage =
279
- typeof data.pipeline_stage === 'string' && data.pipeline_stage.trim().length
280
- ? data.pipeline_stage.trim()
281
- : null
282
- const amount = normalizeAmount(data.value_amount)
283
- const currency =
284
- typeof data.value_currency === 'string' && data.value_currency.trim().length
285
- ? data.value_currency.trim().toUpperCase()
286
- : null
287
- const probability = normalizeProbability(data.probability)
288
- const expected = normalizeTimestamp(data.expected_close_at)
289
- const created = normalizeTimestamp(data.created_at)
290
- const updated = normalizeTimestamp(data.updated_at)
291
- const rawPeople = Array.isArray(data.people) ? data.people : []
292
- const people: DealAssociation[] = rawPeople
293
- .map((entry) => {
294
- if (!entry || typeof entry !== 'object') return null
295
- const ref = entry as Record<string, unknown>
296
- const personId = typeof ref.id === 'string' ? ref.id : null
297
- if (!personId) return null
298
- const label =
299
- typeof ref.label === 'string' && ref.label.trim().length
300
- ? ref.label.trim()
301
- : personId
302
- return { id: personId, label }
303
- })
304
- .filter((entry): entry is DealAssociation => !!entry)
305
- const rawCompanies = Array.isArray(data.companies) ? data.companies : []
306
- const companies: DealAssociation[] = rawCompanies
307
- .map((entry) => {
308
- if (!entry || typeof entry !== 'object') return null
309
- const ref = entry as Record<string, unknown>
310
- const companyId = typeof ref.id === 'string' ? ref.id : null
311
- if (!companyId) return null
312
- const label =
313
- typeof ref.label === 'string' && ref.label.trim().length
314
- ? ref.label.trim()
315
- : companyId
316
- return { id: companyId, label }
317
- })
318
- .filter((entry): entry is DealAssociation => !!entry)
319
- deals.push({
320
- id,
321
- title,
322
- status,
323
- pipelineStage: stage,
324
- pipelineId: typeof data.pipeline_id === 'string' ? data.pipeline_id : null,
325
- pipelineStageId: typeof data.pipeline_stage_id === 'string' ? data.pipeline_stage_id : null,
326
- valueAmount: amount,
327
- valueCurrency: currency,
328
- probability,
329
- expectedCloseAt: expected.iso,
330
- expectedCloseAtTs: expected.ts,
331
- createdAt: created.iso,
332
- createdAtTs: created.ts,
333
- updatedAt: updated.iso,
334
- people,
335
- companies,
336
- })
643
+ return call.ok ? call.result ?? null : null
644
+ },
645
+ })
646
+
647
+ const aggregateByStage = React.useMemo(() => {
648
+ const map = new Map<string, LaneAggregate>()
649
+ const data = aggregateQuery.data
650
+ if (!data) return map
651
+ for (const row of data.perStage) {
652
+ map.set(row.stageId, {
653
+ count: row.count,
654
+ totalInBaseCurrency: row.totalInBaseCurrency,
655
+ byCurrency: row.byCurrency,
656
+ baseCurrencyCode: data.baseCurrencyCode,
657
+ // Default to "complete conversion" when the field is absent (older response, or no
658
+ // base currency case where the route still returns the field). The aggregate route
659
+ // explicitly sets `convertedAll: false` when at least one currency was excluded.
660
+ convertedAll: row.convertedAll ?? true,
661
+ missingRateCurrencies: row.missingRateCurrencies ?? [],
337
662
  })
663
+ }
664
+ return map
665
+ }, [aggregateQuery.data])
338
666
 
339
- const total = typeof payload?.total === 'number' ? payload.total : deals.length
340
- return { deals, total }
341
- },
667
+ const stagesData = stagesQuery.data ?? []
668
+ const aggregateData = aggregateQuery.data
669
+
670
+ // Pre-compute the lane shape from stages + aggregate so unassigned cards still show up if any
671
+ const lanes = React.useMemo<LaneStage[]>(() => {
672
+ const knownStageIds = new Set(stagesData.map((stage) => stage.id))
673
+ const hasUnassigned = (aggregateData?.perStage ?? []).some(
674
+ (row) => row.stageId === '__unassigned' || (!!row.stageId && !knownStageIds.has(row.stageId)),
675
+ )
676
+ return buildLaneStages(stagesData, unassignedLabel, hasUnassigned)
677
+ }, [stagesData, aggregateData, unassignedLabel])
678
+
679
+ // Resolve the active SortOption to an API sort once per change. Owner sort has no server
680
+ // representation (only owner_user_id exists in the deals table; the displayed name is resolved
681
+ // client-side), so it falls back to `updatedAt desc` server-side and is re-sorted client-side.
682
+ const apiSort = React.useMemo(() => {
683
+ const mapped = mapSortOptionToApi(sortBy)
684
+ return mapped ?? { sortField: 'updatedAt' as const, sortDir: 'desc' as const }
685
+ }, [sortBy])
686
+
687
+ const laneQueries = useQueries({
688
+ queries: lanes.map((stage) => {
689
+ return {
690
+ queryKey: [
691
+ 'customers',
692
+ 'deals',
693
+ 'kanban-lane',
694
+ `scope:${scopeVersion}`,
695
+ `pipeline:${filterSignature.pipelineId ?? 'none'}`,
696
+ `search:${filterSignature.search}`,
697
+ `status:${filterSignature.status}`,
698
+ `owners:${filterSignature.owners}`,
699
+ `people:${filterSignature.people}`,
700
+ `companies:${filterSignature.companies}`,
701
+ `close:${filterSignature.closeFrom}-${filterSignature.closeTo}`,
702
+ // Sort affects which 25 deals come back on page 1, so it MUST be in the key —
703
+ // otherwise switching from "Value (high to low)" to "Close (soonest)" reuses
704
+ // the old (wrong-sort) cache and silently keeps the wrong slice on screen.
705
+ `sort:${apiSort.sortField}:${apiSort.sortDir}`,
706
+ `currency:${filterSignature.currency}`,
707
+ `stage:${stage.id}`,
708
+ ],
709
+ enabled: !!selectedPipelineId,
710
+ staleTime: 30_000,
711
+ queryFn: async () => {
712
+ if (!selectedPipelineId) return { items: [] as DealApiRecord[], total: 0 }
713
+ const params = new URLSearchParams()
714
+ params.set('page', '1')
715
+ params.set('pageSize', String(LANE_PAGE_SIZE))
716
+ params.set('pipelineId', selectedPipelineId)
717
+ params.set('pipelineStageId', stage.id)
718
+ params.set('sortField', apiSort.sortField)
719
+ params.set('sortDir', apiSort.sortDir)
720
+ if (filterSignature.search.length) params.set('search', filterSignature.search)
721
+ for (const status of statusFilters) params.append('status', status)
722
+ for (const ownerId of ownerFilters) params.append('ownerUserId', ownerId)
723
+ for (const personId of peopleFilters) params.append('personId', personId)
724
+ for (const companyId of companyFilters) params.append('companyId', companyId)
725
+ if (closeDateFilter.from) params.set('expectedCloseAtFrom', closeDateFilter.from)
726
+ if (closeDateFilter.to) params.set('expectedCloseAtTo', closeDateFilter.to)
727
+ if (currencyFilter) params.set('valueCurrency', currencyFilter)
728
+ const payload = await readApiResultOrThrow<{ items?: DealApiRecord[]; total?: number }>(
729
+ `/api/customers/deals?${params.toString()}`,
730
+ undefined,
731
+ {
732
+ errorMessage: translateWithFallback(
733
+ t,
734
+ 'customers.deals.pipeline.loadError',
735
+ 'Failed to load deals.',
736
+ ),
737
+ },
738
+ )
739
+ const items = Array.isArray(payload?.items) ? (payload!.items as DealApiRecord[]) : []
740
+ return { items, total: typeof payload?.total === 'number' ? payload.total : items.length }
741
+ },
742
+ }
743
+ }),
342
744
  })
343
745
 
344
- const deals = dealsQuery.data?.deals ?? []
345
- const total = dealsQuery.data?.total ?? deals.length
346
- const dealMap = React.useMemo(() => createDealMap(deals), [deals])
347
- const groupedDeals = React.useMemo(() => groupDealsByStageId(deals), [deals])
348
- const stages = React.useMemo(
349
- () => buildStageDefinitionsFromPipelineStages(stagesQuery.data ?? [], deals, t),
350
- [stagesQuery.data, deals, t],
746
+ const laneState = React.useMemo(() => {
747
+ const dealsByStage = new Map<string, DealCardData[]>()
748
+ const stageIdByDealId = new Map<string, string>()
749
+ const allDeals: DealCardData[] = []
750
+ let total = 0
751
+ laneQueries.forEach((query, idx) => {
752
+ const stage = lanes[idx]
753
+ if (!stage) return
754
+ const firstPage = query.data?.items ?? []
755
+ const extra = extraCardsByStage[stage.id] ?? []
756
+ // Merge first page + appended pages, deduping by id (in case of races/optimistic moves)
757
+ const seen = new Set<string>()
758
+ const merged: DealApiRecord[] = []
759
+ for (const item of [...firstPage, ...extra]) {
760
+ if (!item.id || seen.has(item.id)) continue
761
+ seen.add(item.id)
762
+ merged.push(item)
763
+ }
764
+ const list: DealCardData[] = []
765
+ for (const item of merged) {
766
+ const mapped = mapDealRecord(item, fallbackTitle)
767
+ if (!mapped) continue
768
+ list.push(mapped)
769
+ const stageId =
770
+ typeof item.pipeline_stage_id === 'string' && item.pipeline_stage_id.trim().length
771
+ ? item.pipeline_stage_id
772
+ : '__unassigned'
773
+ stageIdByDealId.set(mapped.id, stageId)
774
+ allDeals.push(mapped)
775
+ }
776
+ dealsByStage.set(stage.id, list)
777
+ total += list.length
778
+ })
779
+ return { dealsByStage, stageIdByDealId, allDeals, total }
780
+ }, [laneQueries, lanes, fallbackTitle, extraCardsByStage])
781
+
782
+ const rawDeals = laneState.allDeals
783
+ const stageIdByDealId = laneState.stageIdByDealId
784
+ const total = aggregateData?.perStage?.reduce((sum, row) => sum + row.count, 0) ?? laneState.total
785
+
786
+ /**
787
+ * Sum of `totalInBaseCurrency` across every visible lane — gives the operator one
788
+ * authoritative "what's this pipeline worth?" number at the top of the kanban. Also
789
+ * aggregates the per-currency breakdown so the same `LaneCurrencyBreakdown` popover
790
+ * can show the board-wide split. We compute `convertedAll` as the AND of every lane's
791
+ * flag: any single missing-rate currency drops the board to "partial" so the operator
792
+ * can't read the headline as authoritative.
793
+ */
794
+ const boardSummary = React.useMemo(() => {
795
+ const perStage = aggregateData?.perStage ?? []
796
+ if (perStage.length === 0) {
797
+ return null
798
+ }
799
+ const totalsByCurrency = new Map<string, { total: number; count: number }>()
800
+ let totalInBaseCurrency = 0
801
+ let convertedAll = true
802
+ const missingRateCurrencies = new Set<string>()
803
+ for (const row of perStage) {
804
+ totalInBaseCurrency += row.totalInBaseCurrency
805
+ if (!(row.convertedAll ?? true)) convertedAll = false
806
+ for (const c of row.missingRateCurrencies ?? []) missingRateCurrencies.add(c)
807
+ for (const cur of row.byCurrency) {
808
+ const entry = totalsByCurrency.get(cur.currency) ?? { total: 0, count: 0 }
809
+ entry.total += cur.total
810
+ entry.count += cur.count
811
+ totalsByCurrency.set(cur.currency, entry)
812
+ }
813
+ }
814
+ const rows = Array.from(totalsByCurrency.entries())
815
+ .map(([currency, value]) => ({ currency, total: value.total, count: value.count }))
816
+ .sort((a, b) => b.total - a.total)
817
+ return {
818
+ baseCurrencyCode: aggregateData?.baseCurrencyCode ?? null,
819
+ totalInBaseCurrency,
820
+ convertedAll,
821
+ missingRateCurrencies: Array.from(missingRateCurrencies),
822
+ rows,
823
+ }
824
+ }, [aggregateData])
825
+
826
+ const deals = React.useMemo(() => {
827
+ if (ownerNamesById.size === 0) return rawDeals
828
+ return rawDeals.map((deal) => {
829
+ if (!deal.owner) return deal
830
+ const resolvedLabel = ownerNamesById.get(deal.owner.userId)
831
+ if (!resolvedLabel || resolvedLabel === deal.owner.label) return deal
832
+ return { ...deal, owner: { ...deal.owner, label: resolvedLabel } }
833
+ })
834
+ }, [ownerNamesById, rawDeals])
835
+
836
+ const dealsByStage = React.useMemo(() => {
837
+ const map = new Map<string, DealCardData[]>()
838
+ if (ownerNamesById.size === 0) {
839
+ for (const [stageId, list] of laneState.dealsByStage) map.set(stageId, list)
840
+ return map
841
+ }
842
+ for (const [stageId, list] of laneState.dealsByStage) {
843
+ map.set(
844
+ stageId,
845
+ list.map((deal) => {
846
+ if (!deal.owner) return deal
847
+ const resolvedLabel = ownerNamesById.get(deal.owner.userId)
848
+ if (!resolvedLabel || resolvedLabel === deal.owner.label) return deal
849
+ return { ...deal, owner: { ...deal.owner, label: resolvedLabel } }
850
+ }),
851
+ )
852
+ }
853
+ return map
854
+ }, [laneState.dealsByStage, ownerNamesById])
855
+
856
+ // Pre-sort each lane's deals once per data/sort change. This keeps the array reference
857
+ // stable across page re-renders (e.g. drag-start, filter changes that don't touch this
858
+ // lane), so Lane.memo's `prev.deals !== next.deals` check stays cheap — without this
859
+ // the inline `sortDeals(...)` in the JSX allocated a new array every render and busted
860
+ // every Lane's memo, cascading reconciliation through every memoized DealCard.
861
+ const sortedDealsByStage = React.useMemo(() => {
862
+ const map = new Map<string, DealCardData[]>()
863
+ for (const [stageId, list] of dealsByStage) {
864
+ map.set(stageId, sortDeals(list, sortBy))
865
+ }
866
+ return map
867
+ }, [dealsByStage, sortBy])
868
+
869
+ const handleLoadMoreInLane = React.useCallback(
870
+ async (stageId: string) => {
871
+ if (!selectedPipelineId) return
872
+ if (loadingMoreByStage[stageId]) return
873
+ // Page 1 = first 25 (initial useQueries fetch). Subsequent pages come from extraCardsByStage.
874
+ const already = extraCardsByStage[stageId]?.length ?? 0
875
+ const nextPage = Math.floor((LANE_PAGE_SIZE + already) / LANE_PAGE_SIZE) + 1
876
+ setLoadingMoreByStage((prev) => ({ ...prev, [stageId]: true }))
877
+ try {
878
+ const params = new URLSearchParams()
879
+ params.set('page', String(nextPage))
880
+ params.set('pageSize', String(LANE_PAGE_SIZE))
881
+ params.set('pipelineId', selectedPipelineId)
882
+ params.set('pipelineStageId', stageId)
883
+ // Show-more MUST request the same sort as page 1; otherwise the appended cards land
884
+ // in an arbitrary order relative to the cards already on screen.
885
+ params.set('sortField', apiSort.sortField)
886
+ params.set('sortDir', apiSort.sortDir)
887
+ if (filterSignature.search.length) params.set('search', filterSignature.search)
888
+ for (const status of statusFilters) params.append('status', status)
889
+ for (const ownerId of ownerFilters) params.append('ownerUserId', ownerId)
890
+ for (const personId of peopleFilters) params.append('personId', personId)
891
+ for (const companyId of companyFilters) params.append('companyId', companyId)
892
+ if (closeDateFilter.from) params.set('expectedCloseAtFrom', closeDateFilter.from)
893
+ if (closeDateFilter.to) params.set('expectedCloseAtTo', closeDateFilter.to)
894
+ if (currencyFilter) params.set('valueCurrency', currencyFilter)
895
+ const call = await apiCall<{ items?: DealApiRecord[] }>(
896
+ `/api/customers/deals?${params.toString()}`,
897
+ )
898
+ if (!call.ok) return
899
+ const items = Array.isArray(call.result?.items) ? (call.result!.items as DealApiRecord[]) : []
900
+ setExtraCardsByStage((prev) => ({
901
+ ...prev,
902
+ [stageId]: [...(prev[stageId] ?? []), ...items],
903
+ }))
904
+ } finally {
905
+ setLoadingMoreByStage((prev) => {
906
+ const next = { ...prev }
907
+ delete next[stageId]
908
+ return next
909
+ })
910
+ }
911
+ },
912
+ [
913
+ selectedPipelineId,
914
+ loadingMoreByStage,
915
+ extraCardsByStage,
916
+ filterSignature.search,
917
+ statusFilters,
918
+ ownerFilters,
919
+ peopleFilters,
920
+ companyFilters,
921
+ closeDateFilter.from,
922
+ closeDateFilter.to,
923
+ apiSort,
924
+ ],
351
925
  )
352
926
 
353
- const dateFormatter = React.useMemo(
354
- () =>
355
- new Intl.DateTimeFormat(undefined, {
356
- dateStyle: 'medium',
357
- }),
927
+ // Page chrome loading/error indicators derived from per-lane queries
928
+ const isInitialLoading =
929
+ !!selectedPipelineId && lanes.length === 0
930
+ ? stagesQuery.isLoading
931
+ : laneQueries.some((laneQuery) => laneQuery.isLoading && !laneQuery.data)
932
+ const firstError: unknown = laneQueries.find((laneQuery) => laneQuery.isError)?.error ?? null
933
+
934
+ // Centralized invalidation so writes refresh per-lane card queries, the aggregate, and drop stale extras
935
+ const invalidateKanbanData = React.useCallback(() => {
936
+ queryClient
937
+ .invalidateQueries({ queryKey: ['customers', 'deals', 'kanban-lane'] })
938
+ .catch(() => {})
939
+ queryClient
940
+ .invalidateQueries({ queryKey: ['customers', 'deals', 'kanban-aggregate'] })
941
+ .catch(() => {})
942
+ // Clear appended pages — they'll be re-fetched on demand by the user
943
+ setExtraCardsByStage({})
944
+ }, [queryClient])
945
+
946
+ const trackedBulkProgressJobIdsRef = React.useRef(new Set<string>())
947
+
948
+ const trackBulkProgressJob = React.useCallback((jobId: string | null | undefined) => {
949
+ if (!jobId) return
950
+ trackedBulkProgressJobIdsRef.current.add(jobId)
951
+ }, [])
952
+
953
+ const clearTrackedBulkProgressJob = React.useCallback((jobId: string | null): boolean => {
954
+ if (!jobId) return false
955
+ return trackedBulkProgressJobIdsRef.current.delete(jobId)
956
+ }, [])
957
+
958
+ useAppEvent(
959
+ 'progress.job.completed',
960
+ (event) => {
961
+ const payload = event.payload as { jobId?: unknown } | null | undefined
962
+ const jobId = typeof payload?.jobId === 'string' ? payload.jobId : null
963
+ if (!clearTrackedBulkProgressJob(jobId)) return
964
+ invalidateKanbanData()
965
+ },
966
+ [clearTrackedBulkProgressJob, invalidateKanbanData],
967
+ )
968
+
969
+ useAppEvent(
970
+ 'progress.job.failed',
971
+ (event) => {
972
+ const payload = event.payload as { jobId?: unknown } | null | undefined
973
+ const jobId = typeof payload?.jobId === 'string' ? payload.jobId : null
974
+ clearTrackedBulkProgressJob(jobId)
975
+ },
976
+ [clearTrackedBulkProgressJob],
977
+ )
978
+
979
+ useAppEvent(
980
+ 'progress.job.cancelled',
981
+ (event) => {
982
+ const payload = event.payload as { jobId?: unknown } | null | undefined
983
+ const jobId = typeof payload?.jobId === 'string' ? payload.jobId : null
984
+ clearTrackedBulkProgressJob(jobId)
985
+ },
986
+ [clearTrackedBulkProgressJob],
987
+ )
988
+
989
+ const sensors = useSensors(
990
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
991
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
992
+ )
993
+
994
+ const [activeDragDealId, setActiveDragDealId] = React.useState<string | null>(null)
995
+ const activeDragDeal = React.useMemo(
996
+ () => (activeDragDealId ? deals.find((deal) => deal.id === activeDragDealId) ?? null : null),
997
+ [activeDragDealId, deals],
998
+ )
999
+
1000
+ // Cards are draggable but NOT droppable; only lanes are droppable. That means collision
1001
+ // detection iterates only ~7 lane rects per pointer move. `pointerWithin` is the right
1002
+ // semantic — return the lane whose rect contains the pointer. We DROPPED the
1003
+ // `rectIntersection` fallback because it was running on every move alongside `pointerWithin`
1004
+ // (2× the rect math), and the only case it covered (cursor outside every lane rect) is
1005
+ // already handled by dnd-kit returning empty collisions → no drop target shown.
1006
+ const collisionDetection = React.useCallback<CollisionDetection>((args) => pointerWithin(args), [])
1007
+
1008
+ // Measure droppables once at drag start (not on every pointer move). Kanban lanes don't
1009
+ // resize during a drag, so re-measuring on every move is pure overhead — this single line
1010
+ // is the biggest single-shot perf win for dnd-kit kanbans.
1011
+ // dnd-kit will still automatically re-measure if a layout-affecting change happens (e.g.
1012
+ // the scroller scrolls or a new lane appears).
1013
+ const measuringConfig = React.useMemo(
1014
+ () => ({ droppable: { strategy: MeasuringStrategy.BeforeDragging } }),
358
1015
  [],
359
1016
  )
360
1017
 
361
- const updateStageMutation = useMutation({
362
- mutationFn: async ({ id, pipelineStageId }: { id: string; pipelineStageId: string }) => {
363
- await apiCallOrThrow(
364
- '/api/customers/deals',
365
- {
366
- method: 'PUT',
367
- headers: { 'content-type': 'application/json' },
368
- body: JSON.stringify({ id, pipelineStageId }),
1018
+ const moveMutationContextId = 'customers-deals-kanban:stage-move'
1019
+ const { runMutation: runMoveMutation, retryLastMutation } = useGuardedMutation<{
1020
+ formId: string
1021
+ resourceKind: string
1022
+ resourceId: string
1023
+ retryLastMutation: () => Promise<boolean>
1024
+ }>({
1025
+ contextId: moveMutationContextId,
1026
+ blockedMessage: translateWithFallback(
1027
+ t,
1028
+ 'ui.forms.flash.saveBlocked',
1029
+ 'Save blocked by validation',
1030
+ ),
1031
+ })
1032
+
1033
+ // Deal mutation context is declared here (not next to the dialog/bulk handlers further
1034
+ // down) because `bulkMoveDealsToStage` — declared right after `moveDealToStage` so
1035
+ // `handleDragEnd` can dispatch to it — depends on `runDealMutation`/`retryDealMutation`.
1036
+ // Hoisting the hook keeps the lexical ordering valid for `useCallback` dependency arrays.
1037
+ const dealMutationContextId = 'customers-deals-kanban:deal-mutation'
1038
+ const { runMutation: runDealMutation, retryLastMutation: retryDealMutation } = useGuardedMutation<{
1039
+ formId: string
1040
+ resourceKind: string
1041
+ resourceId: string
1042
+ retryLastMutation: () => Promise<boolean>
1043
+ }>({
1044
+ contextId: dealMutationContextId,
1045
+ blockedMessage: translateWithFallback(
1046
+ t,
1047
+ 'ui.forms.flash.saveBlocked',
1048
+ 'Save blocked by validation',
1049
+ ),
1050
+ })
1051
+
1052
+ const moveDealToStage = React.useCallback(
1053
+ (dealId: string, targetStageId: string) => {
1054
+ if (!targetStageId || targetStageId === '__unassigned') return
1055
+ const currentStageId = stageIdByDealId.get(dealId)
1056
+ if (currentStageId === targetStageId) return
1057
+
1058
+ // Optimistic update: move the card between the per-lane query caches
1059
+ const laneCachePredicate = { queryKey: ['customers', 'deals', 'kanban-lane'] as const }
1060
+ const snapshot = queryClient.getQueriesData<{ items: DealApiRecord[]; total: number }>(laneCachePredicate)
1061
+ const extraSnapshot = extraCardsByStage
1062
+ let movingItem: DealApiRecord | null = null
1063
+ for (const [key, data] of snapshot) {
1064
+ if (!data) continue
1065
+ const idx = data.items.findIndex((it) => it.id === dealId)
1066
+ if (idx < 0) continue
1067
+ const stageKey = (key as readonly unknown[]).find((part) => typeof part === 'string' && (part as string).startsWith('stage:'))
1068
+ if (typeof stageKey !== 'string') continue
1069
+ const stageId = stageKey.slice('stage:'.length)
1070
+ // remove from source lane's page 1 cache
1071
+ if (stageId === currentStageId) {
1072
+ queryClient.setQueryData(key, {
1073
+ ...data,
1074
+ items: data.items.filter((it) => it.id !== dealId),
1075
+ total: Math.max(0, data.total - 1),
1076
+ })
1077
+ movingItem = { ...data.items[idx], pipeline_stage_id: targetStageId }
1078
+ }
1079
+ }
1080
+ // Also check extra pages for the card if it wasn't on page 1
1081
+ if (!movingItem && currentStageId) {
1082
+ const extras = extraCardsByStage[currentStageId] ?? []
1083
+ const found = extras.find((it) => it.id === dealId)
1084
+ if (found) movingItem = { ...found, pipeline_stage_id: targetStageId }
1085
+ }
1086
+ // Remove from source lane's extra-pages cache
1087
+ if (currentStageId && extraCardsByStage[currentStageId]?.some((it) => it.id === dealId)) {
1088
+ setExtraCardsByStage((prev) => {
1089
+ const list = prev[currentStageId] ?? []
1090
+ return { ...prev, [currentStageId]: list.filter((it) => it.id !== dealId) }
1091
+ })
1092
+ }
1093
+ if (movingItem) {
1094
+ // add to target lane caches (if they are loaded)
1095
+ for (const [key, data] of snapshot) {
1096
+ if (!data) continue
1097
+ const stageKey = (key as readonly unknown[]).find((part) => typeof part === 'string' && (part as string).startsWith('stage:'))
1098
+ if (typeof stageKey !== 'string') continue
1099
+ const stageId = stageKey.slice('stage:'.length)
1100
+ if (stageId !== targetStageId) continue
1101
+ queryClient.setQueryData(key, {
1102
+ ...data,
1103
+ items: [movingItem!, ...data.items],
1104
+ total: data.total + 1,
1105
+ })
1106
+ }
1107
+ }
1108
+ setPendingDealId(dealId)
1109
+
1110
+ runMoveMutation({
1111
+ operation: async () => {
1112
+ await apiCallOrThrow(
1113
+ '/api/customers/deals',
1114
+ {
1115
+ method: 'PUT',
1116
+ headers: { 'content-type': 'application/json' },
1117
+ body: JSON.stringify({ id: dealId, pipelineStageId: targetStageId }),
1118
+ },
1119
+ {
1120
+ errorMessage: translateWithFallback(
1121
+ t,
1122
+ 'customers.deals.pipeline.moveError',
1123
+ 'Failed to update deal stage.',
1124
+ ),
1125
+ },
1126
+ )
369
1127
  },
370
- { errorMessage: translate('customers.deals.pipeline.moveError', 'Failed to update deal stage.') },
371
- )
372
- return { id, pipelineStageId }
1128
+ context: {
1129
+ formId: moveMutationContextId,
1130
+ resourceKind: 'customers.deal',
1131
+ resourceId: dealId,
1132
+ retryLastMutation,
1133
+ },
1134
+ })
1135
+ .then(() => {
1136
+ flash(
1137
+ translateWithFallback(t, 'customers.deals.pipeline.moveSuccess', 'Deal updated.'),
1138
+ 'success',
1139
+ )
1140
+ })
1141
+ .catch((error: unknown) => {
1142
+ // Roll back optimistic update by restoring the prior cache state
1143
+ for (const [key, data] of snapshot) {
1144
+ queryClient.setQueryData(key, data)
1145
+ }
1146
+ // Restore extra-pages cache to pre-move snapshot
1147
+ setExtraCardsByStage(extraSnapshot)
1148
+ const message =
1149
+ error instanceof Error && error.message
1150
+ ? error.message
1151
+ : translateWithFallback(
1152
+ t,
1153
+ 'customers.deals.pipeline.moveError',
1154
+ 'Failed to update deal stage.',
1155
+ )
1156
+ flash(message, 'error')
1157
+ })
1158
+ .finally(() => {
1159
+ setPendingDealId(null)
1160
+ invalidateKanbanData()
1161
+ })
373
1162
  },
374
- onMutate: async ({ id, pipelineStageId }) => {
375
- setPendingDealId(id)
376
- await queryClient.cancelQueries({ queryKey: dealsKey })
377
- const previous = queryClient.getQueryData<DealsQueryData>(dealsKey)
378
- if (previous) {
379
- const nextDeals = previous.deals.map((deal) =>
380
- deal.id === id ? { ...deal, pipelineStageId } : deal,
1163
+ [invalidateKanbanData, queryClient, retryLastMutation, runMoveMutation, stageIdByDealId, t],
1164
+ )
1165
+
1166
+ // Round-2 UX review item 32: when the operator drags a card that is part of a multi-card
1167
+ // selection, every selected card must travel with it. We mirror `moveDealToStage`'s
1168
+ // optimistic-update pattern but apply it across every id in `dealIds`, capture a single
1169
+ // pre-batch snapshot of every lane cache (so rollback restores the full pre-batch state
1170
+ // atomically on failure), and dispatch the same `/api/customers/deals/bulk-update-stage`
1171
+ // endpoint that powers the `BulkActionsBar` "Change stage" menu. The worker is async, so
1172
+ // we intentionally do NOT call `invalidateKanbanData()` after a successful POST — that
1173
+ // would refetch the pre-move server state and flicker the optimistic UI back. The
1174
+ // `progress.job.completed` listener already triggers an invalidate when the worker is
1175
+ // actually done, which is what we want.
1176
+ const bulkMoveDealsToStage = React.useCallback(
1177
+ async (dealIds: string[], targetStageId: string) => {
1178
+ if (!targetStageId || targetStageId === '__unassigned' || dealIds.length === 0) return
1179
+ const uniqueIds = Array.from(new Set(dealIds))
1180
+ const idsToMove = uniqueIds.filter((id) => stageIdByDealId.get(id) !== targetStageId)
1181
+ if (idsToMove.length === 0) return
1182
+
1183
+ const laneCachePredicate = { queryKey: ['customers', 'deals', 'kanban-lane'] as const }
1184
+ const snapshot = queryClient.getQueriesData<{ items: DealApiRecord[]; total: number }>(laneCachePredicate)
1185
+ const extraSnapshot = extraCardsByStage
1186
+ const newExtras: Record<string, DealApiRecord[]> = { ...extraCardsByStage }
1187
+
1188
+ for (const dealId of idsToMove) {
1189
+ const currentStageId = stageIdByDealId.get(dealId)
1190
+ if (!currentStageId) continue
1191
+ let movingItem: DealApiRecord | null = null
1192
+ for (const [key, data] of snapshot) {
1193
+ if (!data) continue
1194
+ const stageKey = (key as readonly unknown[]).find(
1195
+ (part) => typeof part === 'string' && (part as string).startsWith('stage:'),
1196
+ )
1197
+ if (typeof stageKey !== 'string') continue
1198
+ const stageId = stageKey.slice('stage:'.length)
1199
+ if (stageId !== currentStageId) continue
1200
+ const current = queryClient.getQueryData<{ items: DealApiRecord[]; total: number }>(
1201
+ key as readonly unknown[],
1202
+ )
1203
+ if (!current) continue
1204
+ const idx = current.items.findIndex((it) => it.id === dealId)
1205
+ if (idx < 0) continue
1206
+ movingItem = { ...current.items[idx], pipeline_stage_id: targetStageId }
1207
+ queryClient.setQueryData(key, {
1208
+ ...current,
1209
+ items: current.items.filter((it) => it.id !== dealId),
1210
+ total: Math.max(0, current.total - 1),
1211
+ })
1212
+ break
1213
+ }
1214
+ if (!movingItem) {
1215
+ const extras = newExtras[currentStageId] ?? []
1216
+ const found = extras.find((it) => it.id === dealId)
1217
+ if (found) {
1218
+ movingItem = { ...found, pipeline_stage_id: targetStageId }
1219
+ newExtras[currentStageId] = extras.filter((it) => it.id !== dealId)
1220
+ }
1221
+ } else if (newExtras[currentStageId]?.some((it) => it.id === dealId)) {
1222
+ newExtras[currentStageId] = (newExtras[currentStageId] ?? []).filter((it) => it.id !== dealId)
1223
+ }
1224
+ if (!movingItem) continue
1225
+ for (const [key, data] of snapshot) {
1226
+ if (!data) continue
1227
+ const stageKey = (key as readonly unknown[]).find(
1228
+ (part) => typeof part === 'string' && (part as string).startsWith('stage:'),
1229
+ )
1230
+ if (typeof stageKey !== 'string') continue
1231
+ const stageId = stageKey.slice('stage:'.length)
1232
+ if (stageId !== targetStageId) continue
1233
+ const current = queryClient.getQueryData<{ items: DealApiRecord[]; total: number }>(
1234
+ key as readonly unknown[],
1235
+ )
1236
+ if (!current) continue
1237
+ queryClient.setQueryData(key, {
1238
+ ...current,
1239
+ items: [movingItem, ...current.items],
1240
+ total: current.total + 1,
1241
+ })
1242
+ }
1243
+ }
1244
+ setExtraCardsByStage(newExtras)
1245
+
1246
+ setIsBulkMutating(true)
1247
+ let progressJobId: string | null = null
1248
+ try {
1249
+ await runDealMutation({
1250
+ operation: async () => {
1251
+ const call = await apiCallOrThrow<BulkJobResponse>(
1252
+ '/api/customers/deals/bulk-update-stage',
1253
+ {
1254
+ method: 'POST',
1255
+ headers: { 'content-type': 'application/json' },
1256
+ body: JSON.stringify({
1257
+ ids: idsToMove,
1258
+ pipelineStageId: targetStageId,
1259
+ }),
1260
+ },
1261
+ {
1262
+ errorMessage: translateWithFallback(
1263
+ t,
1264
+ 'customers.deals.kanban.bulk.changeStage.error',
1265
+ 'Failed to start bulk stage update.',
1266
+ ),
1267
+ },
1268
+ )
1269
+ progressJobId = call.result?.progressJobId ?? null
1270
+ },
1271
+ context: {
1272
+ formId: dealMutationContextId,
1273
+ resourceKind: 'customers.deals.bulk_stage',
1274
+ resourceId: idsToMove.join(','),
1275
+ retryLastMutation: retryDealMutation,
1276
+ },
1277
+ })
1278
+ flash(
1279
+ translateWithFallback(
1280
+ t,
1281
+ 'customers.deals.kanban.bulk.changeStage.queued',
1282
+ 'Bulk stage update started ({count} deals).',
1283
+ { count: idsToMove.length },
1284
+ ),
1285
+ 'success',
381
1286
  )
382
- queryClient.setQueryData<DealsQueryData>(dealsKey, { ...previous, deals: nextDeals })
1287
+ if (progressJobId) {
1288
+ trackBulkProgressJob(progressJobId)
1289
+ }
1290
+ // Selection persists after a successful bulk drag — Asana convention; lets the
1291
+ // operator drag the same set into another stage without re-selecting.
1292
+ } catch (error) {
1293
+ for (const [key, data] of snapshot) {
1294
+ queryClient.setQueryData(key, data)
1295
+ }
1296
+ setExtraCardsByStage(extraSnapshot)
1297
+ const message =
1298
+ error instanceof Error && error.message
1299
+ ? error.message
1300
+ : translateWithFallback(
1301
+ t,
1302
+ 'customers.deals.kanban.bulk.changeStage.error',
1303
+ 'Failed to start bulk stage update.',
1304
+ )
1305
+ flash(message, 'error')
1306
+ } finally {
1307
+ setIsBulkMutating(false)
1308
+ }
1309
+ },
1310
+ [
1311
+ dealMutationContextId,
1312
+ extraCardsByStage,
1313
+ queryClient,
1314
+ retryDealMutation,
1315
+ runDealMutation,
1316
+ stageIdByDealId,
1317
+ t,
1318
+ trackBulkProgressJob,
1319
+ ],
1320
+ )
1321
+
1322
+ // Horizontal scroll arrows — let the user step lane-by-lane instead of using only the bottom slider.
1323
+ const boardScrollerRef = React.useRef<HTMLDivElement | null>(null)
1324
+ const LANE_STEP = 322 // 308px lane width + 14px gap (scaled 1.2x for readability)
1325
+ const [scrollEdges, setScrollEdges] = React.useState<{ atStart: boolean; atEnd: boolean }>({
1326
+ atStart: true,
1327
+ atEnd: false,
1328
+ })
1329
+
1330
+ // Edge-detection tolerance in CSS px. Chrome may report fractional scrollLeft / scrollWidth
1331
+ // values when the page DPI is non-integer or the layout has sub-pixel sizing; a too-tight
1332
+ // threshold here disables the arrow before the user actually reaches the end.
1333
+ const EDGE_TOLERANCE = 4
1334
+
1335
+ const updateScrollEdges = React.useCallback(() => {
1336
+ const el = boardScrollerRef.current
1337
+ if (!el) return
1338
+ const maxScroll = el.scrollWidth - el.clientWidth
1339
+ const nextAtStart = el.scrollLeft <= EDGE_TOLERANCE
1340
+ const nextAtEnd = maxScroll <= EDGE_TOLERANCE || el.scrollLeft >= maxScroll - EDGE_TOLERANCE
1341
+ // Functional update with equality check: avoids a full page re-render every frame of the
1342
+ // scroll animation when atStart/atEnd haven't actually flipped. Without this, the entire
1343
+ // 125+ card tree reconciled on every tick → effective frame rate collapsed.
1344
+ setScrollEdges((prev) =>
1345
+ prev.atStart === nextAtStart && prev.atEnd === nextAtEnd ? prev : { atStart: nextAtStart, atEnd: nextAtEnd },
1346
+ )
1347
+ }, [])
1348
+
1349
+ // Combined ref + listener attachment. Using a callback ref guarantees we wire scroll/resize
1350
+ // listeners the moment the scroller div mounts (even when it's conditionally rendered behind
1351
+ // a loading guard), and cleanly tears them down when it unmounts.
1352
+ const cleanupScrollerRef = React.useRef<(() => void) | null>(null)
1353
+ const setBoardScroller = React.useCallback(
1354
+ (el: HTMLDivElement | null) => {
1355
+ cleanupScrollerRef.current?.()
1356
+ cleanupScrollerRef.current = null
1357
+ boardScrollerRef.current = el
1358
+ if (!el) return
1359
+ updateScrollEdges()
1360
+ el.addEventListener('scroll', updateScrollEdges, { passive: true })
1361
+ const ro = new ResizeObserver(updateScrollEdges)
1362
+ ro.observe(el)
1363
+ cleanupScrollerRef.current = () => {
1364
+ el.removeEventListener('scroll', updateScrollEdges)
1365
+ ro.disconnect()
1366
+ }
1367
+ },
1368
+ [updateScrollEdges],
1369
+ )
1370
+
1371
+ // Also recompute when the lane set changes (cards loaded → scrollWidth grows)
1372
+ React.useEffect(() => {
1373
+ updateScrollEdges()
1374
+ }, [updateScrollEdges, lanes.length])
1375
+
1376
+ // Smooth-scroll helper: animate scrollLeft over ~200ms with cubic easing.
1377
+ // Uses requestAnimationFrame so the animation is synced to the display refresh rate
1378
+ // (handles 60Hz, 120Hz, 144Hz, ProMotion etc.). performance.now() gives sub-ms timing.
1379
+ // We do NOT call updateScrollEdges() inside the step — programmatic scrollLeft writes
1380
+ // DO fire a `scroll` event (which the listener we attached in setBoardScroller handles).
1381
+ // Skipping the redundant per-frame call avoids cascading the whole page tree to re-render.
1382
+ const activeAnimRef = React.useRef<{ cancel: () => void } | null>(null)
1383
+ const animateScrollTo = React.useCallback(
1384
+ (el: HTMLDivElement, target: number) => {
1385
+ activeAnimRef.current?.cancel()
1386
+ const startLeft = el.scrollLeft
1387
+ const clampedTarget = Math.max(0, Math.min(el.scrollWidth - el.clientWidth, target))
1388
+ const distance = clampedTarget - startLeft
1389
+ if (Math.abs(distance) < 1) {
1390
+ updateScrollEdges()
1391
+ return
1392
+ }
1393
+ const duration = 200
1394
+ const hasRaf = typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function'
1395
+ const now = () =>
1396
+ typeof performance !== 'undefined' && typeof performance.now === 'function'
1397
+ ? performance.now()
1398
+ : Date.now()
1399
+ const startTime = now()
1400
+ let handle = 0
1401
+ let cancelled = false
1402
+ const step = () => {
1403
+ if (cancelled) return
1404
+ const t = Math.min(1, (now() - startTime) / duration)
1405
+ const eased = 1 - Math.pow(1 - t, 3)
1406
+ el.scrollLeft = startLeft + distance * eased
1407
+ if (t < 1) {
1408
+ handle = hasRaf
1409
+ ? window.requestAnimationFrame(step)
1410
+ : (window.setTimeout(step, 16) as unknown as number)
1411
+ } else {
1412
+ // Final edge-sync once the animation lands — the scroll listener also fires,
1413
+ // but on some browsers the last `scroll` event is debounced behind the final write.
1414
+ updateScrollEdges()
1415
+ }
1416
+ }
1417
+ handle = hasRaf
1418
+ ? window.requestAnimationFrame(step)
1419
+ : (window.setTimeout(step, 16) as unknown as number)
1420
+ activeAnimRef.current = {
1421
+ cancel: () => {
1422
+ cancelled = true
1423
+ if (hasRaf) window.cancelAnimationFrame(handle)
1424
+ else window.clearTimeout(handle)
1425
+ },
383
1426
  }
384
- return { previous }
385
1427
  },
386
- onError: (error, _variables, context) => {
387
- if (context?.previous) {
388
- queryClient.setQueryData<DealsQueryData>(dealsKey, context.previous)
389
- }
390
- const message =
391
- error instanceof Error && error.message
392
- ? error.message
393
- : translate('customers.deals.pipeline.moveError', 'Failed to update deal stage.')
394
- flash(message, 'error')
1428
+ [updateScrollEdges],
1429
+ )
1430
+
1431
+ // Pre-compute lane left-edge offsets (in scrollLeft coords) so resize-aware "step one lane"
1432
+ // works even when lanes have different widths.
1433
+ const laneOffsets = React.useMemo(() => {
1434
+ const offsets: number[] = []
1435
+ let left = 0
1436
+ for (const stage of lanes) {
1437
+ offsets.push(left)
1438
+ const w = laneWidths[stage.id] ?? DEFAULT_LANE_WIDTH
1439
+ left += w + LANE_GAP
1440
+ }
1441
+ return offsets
1442
+ }, [lanes, laneWidths])
1443
+
1444
+ // Per-direction step: pick the next lane boundary based on the live scrollLeft.
1445
+ // Variable-width-aware: uses laneOffsets instead of a fixed LANE_STEP so resized columns still
1446
+ // snap cleanly. Chrome's scrollLeft can be fractional; we add EDGE_TOLERANCE to dodge that.
1447
+ const stepScroll = React.useCallback(
1448
+ (direction: -1 | 1) => {
1449
+ const el = boardScrollerRef.current
1450
+ if (!el) return false
1451
+ const sl = el.scrollLeft
1452
+ const maxScroll = Math.max(0, el.scrollWidth - el.clientWidth)
1453
+ if (maxScroll <= EDGE_TOLERANCE) return false
1454
+ if (direction < 0 && sl <= EDGE_TOLERANCE) return false
1455
+ if (direction > 0 && sl >= maxScroll - EDGE_TOLERANCE) return false
1456
+
1457
+ let target: number
1458
+ if (direction > 0) {
1459
+ // First lane offset that is meaningfully ahead of the current scroll position
1460
+ const next = laneOffsets.find((off) => off > sl + EDGE_TOLERANCE)
1461
+ target = next ?? maxScroll
1462
+ } else {
1463
+ // Last lane offset that is meaningfully behind the current scroll position
1464
+ let prev = 0
1465
+ for (const off of laneOffsets) {
1466
+ if (off >= sl - EDGE_TOLERANCE) break
1467
+ prev = off
1468
+ }
1469
+ target = prev
1470
+ }
1471
+ if (target > maxScroll - EDGE_TOLERANCE) target = maxScroll
1472
+ if (target < EDGE_TOLERANCE) target = 0
1473
+
1474
+ // No-op safety: if target is essentially the current position, do nothing
1475
+ if (Math.abs(target - sl) < 1) return false
1476
+
1477
+ animateScrollTo(el, target)
1478
+ // Belt-and-suspenders: if the animation library couldn't progress in this browser, force
1479
+ // the target after the animation duration so we never "stick" mid-scroll.
1480
+ window.setTimeout(() => {
1481
+ if (!boardScrollerRef.current) return
1482
+ const cur = boardScrollerRef.current.scrollLeft
1483
+ if (Math.abs(cur - target) > EDGE_TOLERANCE) {
1484
+ boardScrollerRef.current.scrollLeft = target
1485
+ }
1486
+ updateScrollEdges()
1487
+ }, 240)
1488
+ return true
395
1489
  },
396
- onSuccess: () => {
397
- flash(translate('customers.deals.pipeline.moveSuccess', 'Deal updated.'), 'success')
1490
+ [animateScrollTo, updateScrollEdges, laneOffsets],
1491
+ )
1492
+
1493
+ // Press-and-hold continuous scroll. First step fires immediately on press; if the user keeps
1494
+ // the button held, additional steps fire every ~280ms (matched to animation duration) until they
1495
+ // release or the scroller reaches the edge.
1496
+ const holdTimerRef = React.useRef<number | null>(null)
1497
+ const stopContinuousScroll = React.useCallback(() => {
1498
+ if (holdTimerRef.current !== null) {
1499
+ window.clearInterval(holdTimerRef.current)
1500
+ holdTimerRef.current = null
1501
+ }
1502
+ }, [])
1503
+ const startContinuousScroll = React.useCallback(
1504
+ (direction: -1 | 1) => {
1505
+ stopContinuousScroll()
1506
+ const moved = stepScroll(direction)
1507
+ if (!moved) return
1508
+ const HOLD_DELAY = 320
1509
+ const REPEAT_INTERVAL = 240
1510
+ holdTimerRef.current = window.setTimeout(function repeat() {
1511
+ const more = stepScroll(direction)
1512
+ if (!more) {
1513
+ stopContinuousScroll()
1514
+ return
1515
+ }
1516
+ holdTimerRef.current = window.setTimeout(repeat, REPEAT_INTERVAL) as unknown as number
1517
+ }, HOLD_DELAY) as unknown as number
398
1518
  },
399
- onSettled: () => {
400
- setPendingDealId(null)
401
- queryClient.invalidateQueries({ queryKey: dealsKey }).catch(() => {})
1519
+ [stepScroll, stopContinuousScroll],
1520
+ )
1521
+
1522
+ React.useEffect(() => {
1523
+ // Safety: stop any running hold timer on unmount
1524
+ return () => stopContinuousScroll()
1525
+ }, [stopContinuousScroll])
1526
+
1527
+ // Dedup pointerdown + click: real browsers fire both when the user clicks a button.
1528
+ // We start the hold sequence on pointerdown (so press-and-hold feels immediate). The click
1529
+ // event that follows would otherwise scroll an EXTRA lane — we suppress it here.
1530
+ // Keyboard activation (Enter/Space) only fires click, no pointerdown, so it still works.
1531
+ const lastPointerDownAtRef = React.useRef(0)
1532
+ const handleScrollPrev = React.useCallback(() => {
1533
+ if (Date.now() - lastPointerDownAtRef.current < 500) return
1534
+ stepScroll(-1)
1535
+ }, [stepScroll])
1536
+ const handleScrollNext = React.useCallback(() => {
1537
+ if (Date.now() - lastPointerDownAtRef.current < 500) return
1538
+ stepScroll(1)
1539
+ }, [stepScroll])
1540
+ const handleHoldPrevStart = React.useCallback(() => {
1541
+ lastPointerDownAtRef.current = Date.now()
1542
+ startContinuousScroll(-1)
1543
+ }, [startContinuousScroll])
1544
+ const handleHoldNextStart = React.useCallback(() => {
1545
+ lastPointerDownAtRef.current = Date.now()
1546
+ startContinuousScroll(1)
1547
+ }, [startContinuousScroll])
1548
+
1549
+ // Keyboard navigation: ArrowLeft / ArrowRight scroll one lane at a time, mirroring the
1550
+ // on-screen arrow buttons. We intentionally:
1551
+ // - skip when the user is typing inside an input / textarea / select / contenteditable
1552
+ // - skip when modifier keys are held (Cmd/Ctrl/Alt/Shift) so we don't fight browser shortcuts
1553
+ // - skip when any dialog or popover (Radix `[role="dialog"]` / `[data-state="open"]` portals)
1554
+ // is open, so popover keyboard nav (Apply, Tab, Escape, etc.) keeps working
1555
+ // - skip when no kanban scroller is mounted yet (initial loading state)
1556
+ React.useEffect(() => {
1557
+ const onKey = (event: KeyboardEvent) => {
1558
+ if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
1559
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
1560
+ const target = event.target as HTMLElement | null
1561
+ if (target) {
1562
+ const tag = target.tagName
1563
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
1564
+ if (target.isContentEditable) return
1565
+ }
1566
+ // Don't steal arrow keys while a dialog/popover is open — they own keyboard nav.
1567
+ if (typeof document !== 'undefined') {
1568
+ if (document.querySelector('[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]')) return
1569
+ if (document.querySelector('[data-radix-popper-content-wrapper] [data-state="open"]')) return
1570
+ }
1571
+ if (!boardScrollerRef.current) return
1572
+ event.preventDefault()
1573
+ stepScroll(event.key === 'ArrowRight' ? 1 : -1)
1574
+ }
1575
+ window.addEventListener('keydown', onKey)
1576
+ return () => window.removeEventListener('keydown', onKey)
1577
+ }, [stepScroll])
1578
+
1579
+ const handleDragStart = React.useCallback((event: DragStartEvent) => {
1580
+ const id = typeof event.active.id === 'string' ? event.active.id : null
1581
+ setActiveDragDealId(id)
1582
+ }, [])
1583
+
1584
+ const handleDragCancel = React.useCallback(() => {
1585
+ setActiveDragDealId(null)
1586
+ }, [])
1587
+
1588
+ const handleDragEnd = React.useCallback(
1589
+ (event: DragEndEvent) => {
1590
+ setActiveDragDealId(null)
1591
+ const dealId = typeof event.active.id === 'string' ? event.active.id : null
1592
+ if (!dealId) return
1593
+ const overId = event.over?.id
1594
+ if (typeof overId !== 'string') return
1595
+
1596
+ let targetStageId: string | null = null
1597
+ if (overId.startsWith('lane:')) {
1598
+ targetStageId = overId.slice('lane:'.length)
1599
+ } else {
1600
+ targetStageId = stageIdByDealId.get(overId) ?? null
1601
+ }
1602
+ if (!targetStageId) return
1603
+
1604
+ // Round-2 UX review item 32: bulk-drag dispatch. If the dragged card is part of a
1605
+ // multi-card selection, route through `bulkMoveDealsToStage` so every selected card
1606
+ // travels with it. Dragging a NON-selected card is treated as a single-card move
1607
+ // and leaves the existing selection intact (the operator may have selected cards
1608
+ // intentionally and just be moving an unrelated one).
1609
+ const isBulkDrag = selectedDealIds.has(dealId) && selectedDealIds.size > 1
1610
+ if (isBulkDrag) {
1611
+ void bulkMoveDealsToStage(Array.from(selectedDealIds), targetStageId)
1612
+ } else {
1613
+ moveDealToStage(dealId, targetStageId)
1614
+ }
402
1615
  },
403
- })
1616
+ [bulkMoveDealsToStage, moveDealToStage, selectedDealIds, stageIdByDealId],
1617
+ )
1618
+
1619
+ const bulkDragActive = React.useMemo(
1620
+ () => activeDragDealId !== null && selectedDealIds.has(activeDragDealId) && selectedDealIds.size > 1,
1621
+ [activeDragDealId, selectedDealIds],
1622
+ )
404
1623
 
405
- const [draggingId, setDraggingId] = React.useState<string | null>(null)
406
- const [activeLane, setActiveLane] = React.useState<string | null>(null)
407
- const handleActionClick = React.useCallback((event: React.MouseEvent) => {
408
- event.stopPropagation()
1624
+ const handleToggleSelect = React.useCallback((dealId: string) => {
1625
+ setSelectedDealIds((prev) => {
1626
+ const next = new Set(prev)
1627
+ if (next.has(dealId)) next.delete(dealId)
1628
+ else next.add(dealId)
1629
+ return next
1630
+ })
409
1631
  }, [])
410
1632
 
411
- const handleSortChange = React.useCallback((value: string) => {
412
- if (sortOptions.includes(value as SortOption)) setSortBy(value as SortOption)
1633
+ const handleOpenDetail = React.useCallback(
1634
+ (dealId: string) => {
1635
+ router.push(`/backend/customers/deals/${dealId}`)
1636
+ },
1637
+ [router],
1638
+ )
1639
+
1640
+ const handleComingSoon = React.useCallback(
1641
+ (label: string) => {
1642
+ flash(
1643
+ translateWithFallback(
1644
+ t,
1645
+ 'customers.deals.kanban.comingSoon',
1646
+ '{feature} arrives in the next iteration.',
1647
+ { feature: label },
1648
+ ),
1649
+ 'info',
1650
+ )
1651
+ },
1652
+ [t],
1653
+ )
1654
+
1655
+ const handleChipClick = React.useCallback(
1656
+ (chipId: KanbanFilterChip['id']) => {
1657
+ handleComingSoon(
1658
+ translateWithFallback(
1659
+ t,
1660
+ 'customers.deals.kanban.filter.aria.chip',
1661
+ 'Filter by {label}',
1662
+ { label: chipId },
1663
+ ),
1664
+ )
1665
+ },
1666
+ [handleComingSoon, t],
1667
+ )
1668
+
1669
+ const handleCustomizeView = React.useCallback(() => {
1670
+ setCustomizeOpen(true)
413
1671
  }, [])
414
1672
 
415
- const handleDragStart = React.useCallback((dealId: string) => {
416
- setDraggingId(dealId)
1673
+ // "Reset to default" must clear every filter chip the operator may have set, otherwise
1674
+ // the menu copy ("Clear filters and restore columns") is a lie. Previously only the
1675
+ // status filter and sort were reset, which left the operator confused when other chips
1676
+ // (pipeline, owner, people, companies, close date, currency, search) stayed populated.
1677
+ const handleResetView = React.useCallback(() => {
1678
+ setStatusFilters([])
1679
+ setOwnerFilters([])
1680
+ setPeopleFilters([])
1681
+ setCompanyFilters([])
1682
+ setCloseDateFilter({ from: null, to: null })
1683
+ setCurrencyFilter(null)
1684
+ setSearch('')
1685
+ setSortBy(DEFAULT_SORT)
417
1686
  }, [])
418
1687
 
419
- const handleDragEnd = React.useCallback(() => {
420
- setDraggingId(null)
421
- setActiveLane(null)
1688
+ const activePipelineName = React.useMemo(() => {
1689
+ if (!selectedPipelineId) return ''
1690
+ return pipelinesQuery.data?.find((pipeline) => pipeline.id === selectedPipelineId)?.name ?? ''
1691
+ }, [pipelinesQuery.data, selectedPipelineId])
1692
+
1693
+ const stageLabelById = React.useMemo(() => {
1694
+ const map = new Map<string, string>()
1695
+ for (const lane of lanes) map.set(lane.id, lane.label)
1696
+ return map
1697
+ }, [lanes])
1698
+
1699
+ const handleQuickAdd = React.useCallback(
1700
+ (stageId: string) => {
1701
+ if (!selectedPipelineId) return
1702
+ const stageLabel = stageLabelById.get(stageId)
1703
+ if (!stageLabel || stageId === '__unassigned') return
1704
+ setQuickDealContext({
1705
+ pipelineId: selectedPipelineId,
1706
+ pipelineName: activePipelineName,
1707
+ pipelineStageId: stageId,
1708
+ pipelineStageLabel: stageLabel,
1709
+ })
1710
+ },
1711
+ [activePipelineName, selectedPipelineId, stageLabelById],
1712
+ )
1713
+
1714
+ const handleAddStage = React.useCallback(() => {
1715
+ if (!selectedPipelineId) return
1716
+ // Snapshot the current pipeline's stages (sorted by `order`) so the position picker
1717
+ // can offer "After {label}" entries. We sort here rather than relying on stagesData's
1718
+ // ordering because that array's order is technically determined by the API response —
1719
+ // a defensive in-place sort is cheap and survives any future API change.
1720
+ const orderedStages = stagesData
1721
+ .filter((stage) => stage.pipelineId === selectedPipelineId)
1722
+ .slice()
1723
+ .sort((a, b) => a.order - b.order)
1724
+ .map((stage) => ({ id: stage.id, label: stage.label, order: stage.order }))
1725
+ setAddStageContext({
1726
+ pipelineId: selectedPipelineId,
1727
+ pipelineName: activePipelineName,
1728
+ existingStages: orderedStages,
1729
+ })
1730
+ }, [activePipelineName, selectedPipelineId, stagesData])
1731
+
1732
+ const handleDialogCreated = React.useCallback(() => {
1733
+ invalidateKanbanData()
1734
+ queryClient
1735
+ .invalidateQueries({
1736
+ queryKey: ['customers', 'pipeline-stages', `scope:${scopeVersion}`, `pipeline:${selectedPipelineId}`],
1737
+ })
1738
+ .catch(() => {})
1739
+ }, [invalidateKanbanData, scopeVersion, selectedPipelineId])
1740
+
1741
+ const updateDealStatus = React.useCallback(
1742
+ async (dealId: string, status: 'win' | 'loose') => {
1743
+ setPendingDealId(dealId)
1744
+ try {
1745
+ await runDealMutation({
1746
+ operation: async () => {
1747
+ await apiCallOrThrow(
1748
+ '/api/customers/deals',
1749
+ {
1750
+ method: 'PUT',
1751
+ headers: { 'content-type': 'application/json' },
1752
+ body: JSON.stringify({ id: dealId, status }),
1753
+ },
1754
+ {
1755
+ errorMessage: translateWithFallback(
1756
+ t,
1757
+ 'customers.deals.kanban.menu.error.status',
1758
+ 'Failed to update deal status.',
1759
+ ),
1760
+ },
1761
+ )
1762
+ },
1763
+ context: {
1764
+ formId: dealMutationContextId,
1765
+ resourceKind: 'customers.deal',
1766
+ resourceId: dealId,
1767
+ retryLastMutation: retryDealMutation,
1768
+ },
1769
+ })
1770
+ flash(
1771
+ status === 'win'
1772
+ ? translateWithFallback(
1773
+ t,
1774
+ 'customers.deals.kanban.menu.markWon.success',
1775
+ 'Deal marked as won.',
1776
+ )
1777
+ : translateWithFallback(
1778
+ t,
1779
+ 'customers.deals.kanban.menu.markLost.success',
1780
+ 'Deal marked as lost.',
1781
+ ),
1782
+ 'success',
1783
+ )
1784
+ invalidateKanbanData()
1785
+ } catch (error) {
1786
+ const message =
1787
+ error instanceof Error && error.message
1788
+ ? error.message
1789
+ : translateWithFallback(
1790
+ t,
1791
+ 'customers.deals.kanban.menu.error.status',
1792
+ 'Failed to update deal status.',
1793
+ )
1794
+ flash(message, 'error')
1795
+ } finally {
1796
+ setPendingDealId(null)
1797
+ }
1798
+ },
1799
+ [invalidateKanbanData, retryDealMutation, runDealMutation, t],
1800
+ )
1801
+
1802
+ const deleteDeal = React.useCallback(
1803
+ async (dealId: string, dealTitle: string) => {
1804
+ const confirmed = await confirm({
1805
+ title: translateWithFallback(
1806
+ t,
1807
+ 'customers.deals.kanban.menu.delete.confirmTitle',
1808
+ 'Delete deal “{title}”?',
1809
+ { title: dealTitle },
1810
+ ),
1811
+ text: translateWithFallback(
1812
+ t,
1813
+ 'customers.deals.kanban.menu.delete.confirmText',
1814
+ 'This action cannot be undone.',
1815
+ ),
1816
+ variant: 'destructive',
1817
+ confirmText: translateWithFallback(t, 'customers.deals.kanban.menu.delete', 'Delete'),
1818
+ })
1819
+ if (!confirmed) return
1820
+
1821
+ setPendingDealId(dealId)
1822
+ try {
1823
+ await runDealMutation({
1824
+ operation: async () => {
1825
+ await deleteCrud('customers/deals', {
1826
+ body: { id: dealId },
1827
+ errorMessage: translateWithFallback(
1828
+ t,
1829
+ 'customers.deals.kanban.menu.delete.error',
1830
+ 'Failed to delete deal.',
1831
+ ),
1832
+ })
1833
+ },
1834
+ context: {
1835
+ formId: dealMutationContextId,
1836
+ resourceKind: 'customers.deal',
1837
+ resourceId: dealId,
1838
+ retryLastMutation: retryDealMutation,
1839
+ },
1840
+ })
1841
+ flash(
1842
+ translateWithFallback(t, 'customers.deals.kanban.menu.delete.success', 'Deal deleted.'),
1843
+ 'success',
1844
+ )
1845
+ invalidateKanbanData()
1846
+ } catch (error) {
1847
+ const message =
1848
+ error instanceof Error && error.message
1849
+ ? error.message
1850
+ : translateWithFallback(
1851
+ t,
1852
+ 'customers.deals.kanban.menu.delete.error',
1853
+ 'Failed to delete deal.',
1854
+ )
1855
+ flash(message, 'error')
1856
+ } finally {
1857
+ setPendingDealId(null)
1858
+ }
1859
+ },
1860
+ [confirm, invalidateKanbanData, retryDealMutation, runDealMutation, t],
1861
+ )
1862
+
1863
+ const bulkSelectionSummary = React.useMemo(() => {
1864
+ if (selectedDealIds.size === 0) return { count: 0, totalLabel: null, currency: null as string | null, ids: [] as string[] }
1865
+ // Group totals by currency so the label is HONEST when the selection mixes currencies.
1866
+ // The previous implementation summed `valueAmount` across every selected deal and labeled
1867
+ // the result with whichever currency happened to appear first — e.g. selecting €100k + $50k
1868
+ // displayed as "€150k", which is meaningless. We now keep one bucket per currency.
1869
+ const totalsByCurrency = new Map<string, number>()
1870
+ const ids: string[] = []
1871
+ for (const deal of deals) {
1872
+ if (!selectedDealIds.has(deal.id)) continue
1873
+ ids.push(deal.id)
1874
+ if (typeof deal.valueAmount === 'number' && Number.isFinite(deal.valueAmount) && deal.valueAmount > 0) {
1875
+ const code = deal.valueCurrency && deal.valueCurrency.length === 3
1876
+ ? deal.valueCurrency.toUpperCase()
1877
+ : 'USD'
1878
+ totalsByCurrency.set(code, (totalsByCurrency.get(code) ?? 0) + deal.valueAmount)
1879
+ }
1880
+ }
1881
+ const rows = Array.from(totalsByCurrency.entries())
1882
+ .map(([code, amount]) => ({ code, amount }))
1883
+ .sort((a, b) => b.amount - a.amount)
1884
+ const formatOne = (code: string, amount: number) => {
1885
+ try {
1886
+ return new Intl.NumberFormat(undefined, {
1887
+ style: 'currency',
1888
+ currency: code,
1889
+ maximumFractionDigits: 0,
1890
+ }).format(amount)
1891
+ } catch {
1892
+ return `${code} ${Math.round(amount)}`
1893
+ }
1894
+ }
1895
+ let totalLabel: string | null = null
1896
+ if (rows.length === 1) {
1897
+ totalLabel = formatOne(rows[0].code, rows[0].amount)
1898
+ } else if (rows.length >= 2) {
1899
+ // Concatenate per-currency totals with " + " so multi-currency selections stay readable.
1900
+ // Capping at 3 visible rows keeps the bar narrow; the rest collapses into "+N more"
1901
+ // so the operator sees the dominant currencies and an explicit "mixed" hint without
1902
+ // misleading aggregation across rates.
1903
+ const visible = rows.slice(0, 3).map((row) => formatOne(row.code, row.amount))
1904
+ const overflow = rows.length - visible.length
1905
+ totalLabel = overflow > 0 ? `${visible.join(' + ')} +${overflow}` : visible.join(' + ')
1906
+ }
1907
+ // `currency` (singular) is intentionally null when 2+ currencies are present so downstream
1908
+ // consumers (CSV export, change-stage dialog) don't assume a single canonical currency.
1909
+ const currency = rows.length === 1 ? rows[0].code : null
1910
+ return { count: ids.length, totalLabel, currency, ids }
1911
+ }, [deals, selectedDealIds])
1912
+
1913
+ const handleBulkClear = React.useCallback(() => {
1914
+ setSelectedDealIds(new Set())
422
1915
  }, [])
423
1916
 
424
- const handleDrop = React.useCallback(
425
- (stage: StageDefinition) => async (event: React.DragEvent<HTMLDivElement>) => {
426
- event.preventDefault()
427
- setActiveLane(null)
428
- const dealId = event.dataTransfer.getData('text/plain') || draggingId
429
- if (!dealId) return
430
- const deal = dealMap.get(dealId)
1917
+ const stageOptions = React.useMemo(
1918
+ () =>
1919
+ (stagesQuery.data ?? [])
1920
+ .slice()
1921
+ .sort((a, b) => a.order - b.order)
1922
+ .map((stage) => ({ id: stage.id, label: stage.label })),
1923
+ [stagesQuery.data],
1924
+ )
1925
+
1926
+ const handleBulkChangeStage = React.useCallback(
1927
+ async (stageId: string) => {
1928
+ if (bulkSelectionSummary.ids.length === 0) return
1929
+ setIsBulkMutating(true)
1930
+ let progressJobId: string | null = null
1931
+ try {
1932
+ // Bulk writes MUST go through useGuardedMutation so injection modules (record-lock
1933
+ // conflict handling, retry chains, scoped headers) run the same `onBeforeSave`/
1934
+ // `onAfterSave` lifecycle as single-deal writes — see UI AGENTS.md and customers
1935
+ // module MUST rules. `runMutation` throws on guard rejection; the catch block below
1936
+ // surfaces the error message via flash().
1937
+ await runDealMutation({
1938
+ operation: async () => {
1939
+ const call = await apiCallOrThrow<BulkJobResponse>(
1940
+ '/api/customers/deals/bulk-update-stage',
1941
+ {
1942
+ method: 'POST',
1943
+ headers: { 'content-type': 'application/json' },
1944
+ body: JSON.stringify({
1945
+ ids: bulkSelectionSummary.ids,
1946
+ pipelineStageId: stageId,
1947
+ }),
1948
+ },
1949
+ {
1950
+ errorMessage: translateWithFallback(
1951
+ t,
1952
+ 'customers.deals.kanban.bulk.changeStage.error',
1953
+ 'Failed to start bulk stage update.',
1954
+ ),
1955
+ },
1956
+ )
1957
+ progressJobId = call.result?.progressJobId ?? null
1958
+ },
1959
+ context: {
1960
+ formId: dealMutationContextId,
1961
+ resourceKind: 'customers.deals.bulk_stage',
1962
+ resourceId: bulkSelectionSummary.ids.join(','),
1963
+ retryLastMutation: retryDealMutation,
1964
+ },
1965
+ })
1966
+ flash(
1967
+ translateWithFallback(
1968
+ t,
1969
+ 'customers.deals.kanban.bulk.changeStage.queued',
1970
+ 'Bulk stage update started ({count} deals).',
1971
+ { count: bulkSelectionSummary.count },
1972
+ ),
1973
+ 'success',
1974
+ )
1975
+ setChangeStageOpen(false)
1976
+ setSelectedDealIds(new Set())
1977
+ if (progressJobId) {
1978
+ trackBulkProgressJob(progressJobId)
1979
+ }
1980
+ invalidateKanbanData()
1981
+ } catch (error) {
1982
+ const message =
1983
+ error instanceof Error && error.message
1984
+ ? error.message
1985
+ : translateWithFallback(
1986
+ t,
1987
+ 'customers.deals.kanban.bulk.changeStage.error',
1988
+ 'Failed to start bulk stage update.',
1989
+ )
1990
+ flash(message, 'error')
1991
+ } finally {
1992
+ setIsBulkMutating(false)
1993
+ }
1994
+ },
1995
+ [
1996
+ bulkSelectionSummary.count,
1997
+ bulkSelectionSummary.ids,
1998
+ dealMutationContextId,
1999
+ invalidateKanbanData,
2000
+ retryDealMutation,
2001
+ runDealMutation,
2002
+ t,
2003
+ trackBulkProgressJob,
2004
+ ],
2005
+ )
2006
+
2007
+ const handleBulkChangeOwner = React.useCallback(
2008
+ async (ownerUserId: string | null) => {
2009
+ if (bulkSelectionSummary.ids.length === 0) return
2010
+ setIsBulkMutating(true)
2011
+ let progressJobId: string | null = null
2012
+ try {
2013
+ await runDealMutation({
2014
+ operation: async () => {
2015
+ const call = await apiCallOrThrow<BulkJobResponse>(
2016
+ '/api/customers/deals/bulk-update-owner',
2017
+ {
2018
+ method: 'POST',
2019
+ headers: { 'content-type': 'application/json' },
2020
+ body: JSON.stringify({
2021
+ ids: bulkSelectionSummary.ids,
2022
+ ownerUserId,
2023
+ }),
2024
+ },
2025
+ {
2026
+ errorMessage: translateWithFallback(
2027
+ t,
2028
+ 'customers.deals.kanban.bulk.changeOwner.error',
2029
+ 'Failed to start bulk owner update.',
2030
+ ),
2031
+ },
2032
+ )
2033
+ progressJobId = call.result?.progressJobId ?? null
2034
+ },
2035
+ context: {
2036
+ formId: dealMutationContextId,
2037
+ resourceKind: 'customers.deals.bulk_owner',
2038
+ resourceId: bulkSelectionSummary.ids.join(','),
2039
+ retryLastMutation: retryDealMutation,
2040
+ },
2041
+ })
2042
+ flash(
2043
+ translateWithFallback(
2044
+ t,
2045
+ 'customers.deals.kanban.bulk.changeOwner.queued',
2046
+ 'Bulk owner update started ({count} deals).',
2047
+ { count: bulkSelectionSummary.count },
2048
+ ),
2049
+ 'success',
2050
+ )
2051
+ setChangeOwnerOpen(false)
2052
+ setSelectedDealIds(new Set())
2053
+ if (progressJobId) {
2054
+ trackBulkProgressJob(progressJobId)
2055
+ }
2056
+ invalidateKanbanData()
2057
+ } catch (error) {
2058
+ const message =
2059
+ error instanceof Error && error.message
2060
+ ? error.message
2061
+ : translateWithFallback(
2062
+ t,
2063
+ 'customers.deals.kanban.bulk.changeOwner.error',
2064
+ 'Failed to start bulk owner update.',
2065
+ )
2066
+ flash(message, 'error')
2067
+ } finally {
2068
+ setIsBulkMutating(false)
2069
+ }
2070
+ },
2071
+ [
2072
+ bulkSelectionSummary.count,
2073
+ bulkSelectionSummary.ids,
2074
+ dealMutationContextId,
2075
+ invalidateKanbanData,
2076
+ retryDealMutation,
2077
+ runDealMutation,
2078
+ t,
2079
+ trackBulkProgressJob,
2080
+ ],
2081
+ )
2082
+
2083
+ const handleBulkExport = React.useCallback(() => {
2084
+ if (bulkSelectionSummary.ids.length === 0) return
2085
+ const url = buildCrudExportUrl(
2086
+ 'customers/deals',
2087
+ {
2088
+ ids: bulkSelectionSummary.ids.join(','),
2089
+ exportScope: 'view',
2090
+ },
2091
+ 'csv',
2092
+ )
2093
+ if (typeof window !== 'undefined') {
2094
+ window.open(url, '_blank', 'noopener')
2095
+ }
2096
+ }, [bulkSelectionSummary.ids])
2097
+
2098
+ const handleBulkDelete = React.useCallback(async () => {
2099
+ if (bulkSelectionSummary.ids.length === 0) return
2100
+ const confirmed = await confirm({
2101
+ title: translateWithFallback(
2102
+ t,
2103
+ 'customers.deals.kanban.bulk.delete.title',
2104
+ 'Delete {count} deals?',
2105
+ { count: bulkSelectionSummary.count },
2106
+ ),
2107
+ text: translateWithFallback(
2108
+ t,
2109
+ 'customers.deals.kanban.bulk.delete.text',
2110
+ 'This action cannot be undone.',
2111
+ ),
2112
+ variant: 'destructive',
2113
+ confirmText: translateWithFallback(t, 'customers.deals.kanban.bulk.delete', 'Delete'),
2114
+ })
2115
+ if (!confirmed) return
2116
+
2117
+ const rows = deals.filter((deal) => selectedDealIds.has(deal.id)).map((deal) => ({ id: deal.id }))
2118
+
2119
+ setIsBulkMutating(true)
2120
+ try {
2121
+ const { succeeded, failures } = await runBulkDelete(
2122
+ rows,
2123
+ async (row) => {
2124
+ await deleteCrud('customers/deals', {
2125
+ body: { id: row.id },
2126
+ errorMessage: translateWithFallback(
2127
+ t,
2128
+ 'customers.deals.kanban.menu.delete.error',
2129
+ 'Failed to delete deal.',
2130
+ ),
2131
+ })
2132
+ },
2133
+ {
2134
+ fallbackErrorMessage: translateWithFallback(
2135
+ t,
2136
+ 'customers.deals.kanban.menu.delete.error',
2137
+ 'Failed to delete deal.',
2138
+ ),
2139
+ logTag: 'customers.deals.kanban',
2140
+ progress: {
2141
+ jobType: 'customers.deals.bulk_delete',
2142
+ name: translateWithFallback(
2143
+ t,
2144
+ 'customers.deals.kanban.bulk.delete.progress.name',
2145
+ 'Delete selected deals',
2146
+ ),
2147
+ description: translateWithFallback(
2148
+ t,
2149
+ 'customers.deals.kanban.bulk.delete.progress.description',
2150
+ '{count} deals queued for deletion',
2151
+ { count: rows.length },
2152
+ ),
2153
+ meta: { source: 'customers.deals.kanban' },
2154
+ },
2155
+ },
2156
+ )
2157
+
2158
+ if (succeeded.length > 0) {
2159
+ flash(
2160
+ translateWithFallback(
2161
+ t,
2162
+ 'customers.deals.kanban.bulk.delete.success',
2163
+ '{count} deals deleted.',
2164
+ { count: succeeded.length },
2165
+ ),
2166
+ failures.length === 0 ? 'success' : 'warning',
2167
+ )
2168
+ }
2169
+ for (const group of groupBulkDeleteFailures(failures)) {
2170
+ const message =
2171
+ group.count === 1
2172
+ ? group.sampleMessage
2173
+ : translateWithFallback(
2174
+ t,
2175
+ 'customers.deals.kanban.bulk.delete.failedGroup',
2176
+ '{count} deals could not be deleted: {message}',
2177
+ { count: group.count, message: group.sampleMessage },
2178
+ )
2179
+ flash(message, 'error')
2180
+ }
2181
+ setSelectedDealIds(new Set())
2182
+ invalidateKanbanData()
2183
+ } finally {
2184
+ setIsBulkMutating(false)
2185
+ }
2186
+ }, [
2187
+ bulkSelectionSummary.count,
2188
+ bulkSelectionSummary.ids,
2189
+ confirm,
2190
+ deals,
2191
+ queryClient,
2192
+ selectedDealIds,
2193
+ t,
2194
+ ])
2195
+
2196
+ const duplicateDeal = React.useCallback(
2197
+ async (deal: DealCardData) => {
2198
+ setPendingDealId(deal.id)
2199
+ try {
2200
+ const stageId = stageIdByDealId.get(deal.id) ?? null
2201
+ const payload: Record<string, unknown> = {
2202
+ title: translateWithFallback(
2203
+ t,
2204
+ 'customers.deals.kanban.menu.duplicate.titleSuffix',
2205
+ '{title} (copy)',
2206
+ { title: deal.title },
2207
+ ),
2208
+ status: 'open',
2209
+ }
2210
+ if (selectedPipelineId) payload.pipelineId = selectedPipelineId
2211
+ if (stageId && stageId !== '__unassigned') payload.pipelineStageId = stageId
2212
+ if (typeof deal.valueAmount === 'number') payload.valueAmount = deal.valueAmount
2213
+ if (deal.valueCurrency) payload.valueCurrency = deal.valueCurrency
2214
+ if (typeof deal.probability === 'number') payload.probability = deal.probability
2215
+ if (deal.expectedCloseAt) payload.expectedCloseAt = deal.expectedCloseAt
2216
+ if (deal.owner?.userId) payload.ownerUserId = deal.owner.userId
2217
+
2218
+ await runDealMutation({
2219
+ operation: async () => {
2220
+ await apiCallOrThrow(
2221
+ '/api/customers/deals',
2222
+ {
2223
+ method: 'POST',
2224
+ headers: { 'content-type': 'application/json' },
2225
+ body: JSON.stringify(payload),
2226
+ },
2227
+ {
2228
+ errorMessage: translateWithFallback(
2229
+ t,
2230
+ 'customers.deals.kanban.menu.duplicate.error',
2231
+ 'Failed to duplicate deal.',
2232
+ ),
2233
+ },
2234
+ )
2235
+ },
2236
+ context: {
2237
+ formId: dealMutationContextId,
2238
+ resourceKind: 'customers.deal',
2239
+ resourceId: deal.id,
2240
+ retryLastMutation: retryDealMutation,
2241
+ },
2242
+ })
2243
+ flash(
2244
+ translateWithFallback(
2245
+ t,
2246
+ 'customers.deals.kanban.menu.duplicate.success',
2247
+ 'Deal duplicated.',
2248
+ ),
2249
+ 'success',
2250
+ )
2251
+ invalidateKanbanData()
2252
+ } catch (error) {
2253
+ const message =
2254
+ error instanceof Error && error.message
2255
+ ? error.message
2256
+ : translateWithFallback(
2257
+ t,
2258
+ 'customers.deals.kanban.menu.duplicate.error',
2259
+ 'Failed to duplicate deal.',
2260
+ )
2261
+ flash(message, 'error')
2262
+ } finally {
2263
+ setPendingDealId(null)
2264
+ }
2265
+ },
2266
+ [
2267
+ queryClient,
2268
+ retryDealMutation,
2269
+ runDealMutation,
2270
+ selectedPipelineId,
2271
+ stageIdByDealId,
2272
+ t,
2273
+ ],
2274
+ )
2275
+
2276
+ const buildMenuItems = React.useCallback(
2277
+ (deal: DealCardData): RowActionItem[] => [
2278
+ {
2279
+ id: 'open',
2280
+ label: translateWithFallback(t, 'customers.deals.kanban.menu.open', 'Open deal'),
2281
+ onSelect: () => handleOpenDetail(deal.id),
2282
+ },
2283
+ {
2284
+ id: 'edit',
2285
+ label: translateWithFallback(t, 'customers.deals.kanban.menu.edit', 'Edit'),
2286
+ onSelect: () => handleOpenDetail(deal.id),
2287
+ },
2288
+ {
2289
+ id: 'duplicate',
2290
+ label: translateWithFallback(t, 'customers.deals.kanban.menu.duplicate', 'Duplicate'),
2291
+ onSelect: () => void duplicateDeal(deal),
2292
+ },
2293
+ {
2294
+ id: 'move-stage',
2295
+ label: translateWithFallback(t, 'customers.deals.kanban.menu.moveStage', 'Move stage…'),
2296
+ onSelect: () =>
2297
+ setSingleMoveStageContext({
2298
+ dealId: deal.id,
2299
+ currentStageId: stageIdByDealId.get(deal.id) ?? null,
2300
+ }),
2301
+ },
2302
+ {
2303
+ id: 'mark-won',
2304
+ label: translateWithFallback(t, 'customers.deals.kanban.menu.markWon', 'Mark as Won'),
2305
+ onSelect: () => void updateDealStatus(deal.id, 'win'),
2306
+ },
2307
+ {
2308
+ id: 'mark-lost',
2309
+ label: translateWithFallback(t, 'customers.deals.kanban.menu.markLost', 'Mark as Lost'),
2310
+ onSelect: () => void updateDealStatus(deal.id, 'loose'),
2311
+ },
2312
+ {
2313
+ id: 'delete',
2314
+ label: translateWithFallback(t, 'customers.deals.kanban.menu.delete', 'Delete'),
2315
+ destructive: true,
2316
+ onSelect: () => void deleteDeal(deal.id, deal.title),
2317
+ },
2318
+ ],
2319
+ [
2320
+ deleteDeal,
2321
+ duplicateDeal,
2322
+ handleOpenDetail,
2323
+ stageIdByDealId,
2324
+ t,
2325
+ updateDealStatus,
2326
+ ],
2327
+ )
2328
+
2329
+ const handleComposeActivity = React.useCallback(
2330
+ (dealId: string, type: 'call' | 'email' | 'note') => {
2331
+ const deal = deals.find((entry) => entry.id === dealId)
431
2332
  if (!deal) return
432
- if (stage.value === null) {
2333
+ const entityId = deal.primaryCompany?.id
2334
+ if (!entityId) {
433
2335
  flash(
434
- translate('customers.deals.pipeline.unassignedDisabled', 'Moving to "No stage" is not supported.'),
2336
+ translateWithFallback(
2337
+ t,
2338
+ 'customers.deals.kanban.activityComposer.noEntity',
2339
+ 'Link a company to this deal before logging activities here.',
2340
+ ),
435
2341
  'info',
436
2342
  )
437
2343
  return
438
2344
  }
439
- if (deal.pipelineStageId === stage.value) return
440
- updateStageMutation.mutate({ id: dealId, pipelineStageId: stage.value })
2345
+ setActivityContext({
2346
+ dealId,
2347
+ dealTitle: deal.title,
2348
+ type,
2349
+ entityId,
2350
+ })
441
2351
  },
442
- [dealMap, draggingId, translate, updateStageMutation],
2352
+ [deals, t],
443
2353
  )
444
2354
 
445
- const handleDragOver = React.useCallback(
446
- (stageId: string) => (event: React.DragEvent<HTMLDivElement>) => {
447
- event.preventDefault()
448
- event.dataTransfer.dropEffect = 'move'
449
- if (activeLane !== stageId) setActiveLane(stageId)
2355
+ // The placeholder "stub" chips have been replaced by real popovers; pass an empty list.
2356
+ const filterChips = React.useMemo<KanbanFilterChip[]>(() => [], [])
2357
+
2358
+ const pipelineFilterOptions = React.useMemo(
2359
+ () =>
2360
+ (pipelinesQuery.data ?? []).map((pipeline) => ({
2361
+ id: pipeline.id,
2362
+ name: pipeline.name,
2363
+ })),
2364
+ [pipelinesQuery.data],
2365
+ )
2366
+
2367
+ // Async loaders for entity-filter popovers (Owner / People / Companies)
2368
+ const loadOwnerOptions = React.useCallback(
2369
+ async (query: string, _signal: AbortSignal): Promise<EntityFilterOption[]> => {
2370
+ const items = await fetchAssignableStaffMembers(query ?? '', { pageSize: 100 })
2371
+ const opts: EntityFilterOption[] = items
2372
+ .filter((user) => !!user.userId && !!user.displayName)
2373
+ .map((user) => ({ value: user.userId!, label: user.displayName! }))
2374
+ // Cache labels so chip can display readable text for already-selected ids
2375
+ setOwnerLabels((prev) => {
2376
+ const next: Record<string, string> = { ...prev }
2377
+ for (const option of opts) next[option.value] = option.label
2378
+ return next
2379
+ })
2380
+ return opts
450
2381
  },
451
- [activeLane],
452
- )
453
-
454
- const renderLaneHeader = (stage: StageDefinition, count: number) => {
455
- return (
456
- <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3">
457
- <div className="flex items-center gap-2">
458
- <div className="flex flex-col">
459
- <span className="text-sm font-medium">{stage.label}</span>
460
- <span className="text-xs text-muted-foreground">
461
- {translate('customers.deals.pipeline.countLabel', 'Deals: {count}', { count })}
462
- </span>
463
- </div>
464
- </div>
465
- </div>
466
- )
467
- }
2382
+ [],
2383
+ )
2384
+ const loadPeopleOptions = React.useCallback(
2385
+ async (query: string, signal: AbortSignal): Promise<EntityFilterOption[]> => {
2386
+ const params = new URLSearchParams()
2387
+ params.set('page', '1')
2388
+ params.set('pageSize', '50')
2389
+ if (query) params.set('search', query)
2390
+ params.set('sortField', 'displayName')
2391
+ params.set('sortDir', 'asc')
2392
+ // Route the request through `apiCall` so scoped tenant/org headers are attached and the
2393
+ // JSON parser uses the shared safe-read helper. AbortSignal is forwarded through init.
2394
+ const call = await apiCall<{
2395
+ items?: Array<{ id?: string; display_name?: string; first_name?: string; last_name?: string }>
2396
+ }>(`/api/customers/people?${params.toString()}`, { signal })
2397
+ if (!call.ok) return []
2398
+ const items = call.result?.items ?? []
2399
+ const opts: EntityFilterOption[] = []
2400
+ for (const it of items) {
2401
+ if (!it.id) continue
2402
+ const label = (it.display_name && it.display_name.trim().length)
2403
+ ? it.display_name.trim()
2404
+ : [it.first_name, it.last_name].filter(Boolean).join(' ').trim() || it.id.slice(0, 8)
2405
+ opts.push({ value: it.id, label })
2406
+ }
2407
+ setPeopleLabels((prev) => {
2408
+ const next: Record<string, string> = { ...prev }
2409
+ for (const o of opts) next[o.value] = o.label
2410
+ return next
2411
+ })
2412
+ return opts
2413
+ },
2414
+ [],
2415
+ )
2416
+ const loadCompanyOptions = React.useCallback(
2417
+ async (query: string, signal: AbortSignal): Promise<EntityFilterOption[]> => {
2418
+ const params = new URLSearchParams()
2419
+ params.set('page', '1')
2420
+ params.set('pageSize', '50')
2421
+ if (query) params.set('search', query)
2422
+ params.set('sortField', 'display_name')
2423
+ params.set('sortDir', 'asc')
2424
+ const call = await apiCall<{ items?: Array<{ id?: string; display_name?: string }> }>(
2425
+ `/api/customers/companies?${params.toString()}`,
2426
+ { signal },
2427
+ )
2428
+ if (!call.ok) return []
2429
+ const items = call.result?.items ?? []
2430
+ const opts: EntityFilterOption[] = []
2431
+ for (const it of items) {
2432
+ if (!it.id || !it.display_name) continue
2433
+ opts.push({ value: it.id, label: it.display_name })
2434
+ }
2435
+ setCompanyLabels((prev) => {
2436
+ const next: Record<string, string> = { ...prev }
2437
+ for (const o of opts) next[o.value] = o.label
2438
+ return next
2439
+ })
2440
+ return opts
2441
+ },
2442
+ [],
2443
+ )
2444
+
2445
+ const leadingChipsNode = (
2446
+ <>
2447
+ <StatusFilterPopover values={statusFilters} onApply={setStatusFilters} />
2448
+ <PipelineFilterPopover
2449
+ pipelines={pipelineFilterOptions}
2450
+ selectedPipelineId={selectedPipelineId}
2451
+ onApply={setSelectedPipelineId}
2452
+ />
2453
+ {boardSummary && boardSummary.rows.length > 0 ? (
2454
+ <CurrencyFilterPopover
2455
+ rows={boardSummary.rows}
2456
+ baseCurrencyCode={boardSummary.baseCurrencyCode}
2457
+ totalInBaseCurrency={boardSummary.totalInBaseCurrency}
2458
+ headingLabel={translateWithFallback(
2459
+ t,
2460
+ 'customers.deals.kanban.boardSummary.headingLabel',
2461
+ 'PIPELINE',
2462
+ )}
2463
+ headingCount={total}
2464
+ selectedCurrency={currencyFilter}
2465
+ onApply={setCurrencyFilter}
2466
+ />
2467
+ ) : null}
2468
+ <EntityFilterPopover
2469
+ label={translateWithFallback(t, 'customers.deals.kanban.filter.owner', 'Owner')}
2470
+ anyLabel={translateWithFallback(t, 'customers.deals.kanban.filter.all', 'All')}
2471
+ values={ownerFilters}
2472
+ onApply={setOwnerFilters}
2473
+ loadOptions={loadOwnerOptions}
2474
+ labelById={ownerLabels}
2475
+ />
2476
+ <EntityFilterPopover
2477
+ label={translateWithFallback(t, 'customers.deals.kanban.filter.people', 'People')}
2478
+ values={peopleFilters}
2479
+ onApply={setPeopleFilters}
2480
+ loadOptions={loadPeopleOptions}
2481
+ labelById={peopleLabels}
2482
+ />
2483
+ <EntityFilterPopover
2484
+ label={translateWithFallback(t, 'customers.deals.kanban.filter.companies', 'Companies')}
2485
+ values={companyFilters}
2486
+ onApply={setCompanyFilters}
2487
+ loadOptions={loadCompanyOptions}
2488
+ labelById={companyLabels}
2489
+ />
2490
+ <CloseDateFilterPopover value={closeDateFilter} onApply={setCloseDateFilter} />
2491
+ </>
2492
+ )
2493
+
2494
+ const sortNode = <SortByPopover value={sortBy} onApply={setSortBy} />
2495
+
2496
+ const activePipeline = React.useMemo(() => {
2497
+ if (!selectedPipelineId) return null
2498
+ return pipelinesQuery.data?.find((pipeline) => pipeline.id === selectedPipelineId) ?? null
2499
+ }, [pipelinesQuery.data, selectedPipelineId])
2500
+
2501
+ const dragHint = translateWithFallback(
2502
+ t,
2503
+ 'customers.deals.kanban.helper.dragHint',
2504
+ 'Drag cards between lanes to update stage',
2505
+ )
2506
+ const footerCount = translateWithFallback(
2507
+ t,
2508
+ 'customers.deals.kanban.footer.count',
2509
+ 'Showing {visible} of {total} deals in {pipeline} pipeline',
2510
+ {
2511
+ visible: deals.length,
2512
+ total,
2513
+ pipeline: activePipeline?.name ?? '—',
2514
+ },
2515
+ )
468
2516
 
469
2517
  return (
470
2518
  <Page>
471
2519
  <PageBody>
472
- <div className="flex flex-col gap-4">
473
- <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
474
- <div className="flex flex-col">
475
- <h1 className="text-xl font-semibold text-foreground">
476
- {translate('customers.deals.pipeline.title', 'Sales Pipeline')}
2520
+ <div className="flex flex-col gap-2">
2521
+ <Breadcrumb>
2522
+ <BreadcrumbList>
2523
+ <BreadcrumbItem>
2524
+ <BreadcrumbLink asChild>
2525
+ <Link href="/backend">
2526
+ {translateWithFallback(t, 'customers.deals.kanban.breadcrumb.dashboard', 'Dashboard')}
2527
+ </Link>
2528
+ </BreadcrumbLink>
2529
+ </BreadcrumbItem>
2530
+ <BreadcrumbSeparator />
2531
+ <BreadcrumbItem>
2532
+ <BreadcrumbPage>
2533
+ {translateWithFallback(t, 'customers.deals.kanban.breadcrumb.deals', 'Deals')}
2534
+ </BreadcrumbPage>
2535
+ </BreadcrumbItem>
2536
+ </BreadcrumbList>
2537
+ </Breadcrumb>
2538
+
2539
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
2540
+ <div className="flex flex-col gap-1">
2541
+ {/*
2542
+ Heading typography mirrors DS PageHeader (text-xl sm:text-2xl font-semibold
2543
+ leading-tight) so the kanban title matches /backend/customers/people and
2544
+ other backoffice pages. We don't use the PageHeader component itself because
2545
+ it can't host the rich board-summary content (LaneCurrencyBreakdown popover)
2546
+ that lives directly under the title.
2547
+ */}
2548
+ <h1 className="text-xl font-semibold leading-tight text-foreground sm:text-2xl">
2549
+ {translateWithFallback(t, 'customers.deals.kanban.pageTitle', 'Deals')}
477
2550
  </h1>
478
- <p className="text-sm text-muted-foreground">
479
- {translate(
480
- 'customers.deals.pipeline.subtitle',
481
- 'Track deals by pipeline stage and drag them between lanes to update progress.',
2551
+ {boardSummary && boardSummary.rows.length > 0 ? (
2552
+ /*
2553
+ * Pipeline-wide value summary. The headline number is the converted total in
2554
+ * the tenant's base currency, paired with the deal count for context. When at
2555
+ * least one currency lacks an FX rate, we show a `~` prefix and a "partial"
2556
+ * caveat — the `LaneCurrencyBreakdown` popover (anchored on the `+N` chip)
2557
+ * surfaces the full breakdown and missing-rate disclosure. Clicking the
2558
+ * popover trigger here gives the operator the same board-level detail view.
2559
+ */
2560
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
2561
+ <span>
2562
+ {translateWithFallback(
2563
+ t,
2564
+ 'customers.deals.kanban.boardSummary.count',
2565
+ '{count} deals',
2566
+ { count: total },
2567
+ )}
2568
+ </span>
2569
+ {boardSummary.baseCurrencyCode && boardSummary.totalInBaseCurrency > 0 ? (
2570
+ <>
2571
+ <span aria-hidden="true">·</span>
2572
+ <span className="flex items-baseline gap-1 font-semibold text-foreground">
2573
+ {boardSummary.convertedAll ? null : (
2574
+ <span className="text-muted-foreground" aria-hidden="true">
2575
+ ~
2576
+ </span>
2577
+ )}
2578
+ <span>
2579
+ {new Intl.NumberFormat(undefined, {
2580
+ style: 'decimal',
2581
+ maximumFractionDigits: 0,
2582
+ }).format(boardSummary.totalInBaseCurrency)}
2583
+ </span>
2584
+ <span className="text-xs font-medium text-muted-foreground">
2585
+ {boardSummary.baseCurrencyCode}
2586
+ </span>
2587
+ </span>
2588
+ {!boardSummary.convertedAll ? (
2589
+ <span
2590
+ className="text-overline uppercase tracking-wide text-status-warning-text"
2591
+ title={translateWithFallback(
2592
+ t,
2593
+ 'customers.deals.kanban.boardSummary.partialHint',
2594
+ 'Missing FX rates for {currencies} — excluded from total',
2595
+ { currencies: boardSummary.missingRateCurrencies.join(', ') },
2596
+ )}
2597
+ >
2598
+ {translateWithFallback(
2599
+ t,
2600
+ 'customers.deals.kanban.boardSummary.partial',
2601
+ 'partial',
2602
+ )}
2603
+ </span>
2604
+ ) : null}
2605
+ </>
2606
+ ) : null}
2607
+ {boardSummary.rows.length > 1 ? (
2608
+ <LaneCurrencyBreakdown
2609
+ rows={boardSummary.rows}
2610
+ baseCurrencyCode={boardSummary.baseCurrencyCode}
2611
+ totalInBaseCurrency={boardSummary.totalInBaseCurrency}
2612
+ convertedAll={boardSummary.convertedAll}
2613
+ missingRateCurrencies={boardSummary.missingRateCurrencies}
2614
+ headingLabel={translateWithFallback(
2615
+ t,
2616
+ 'customers.deals.kanban.boardSummary.headingLabel',
2617
+ 'PIPELINE',
2618
+ )}
2619
+ headingCount={total}
2620
+ triggerLabel={translateWithFallback(
2621
+ t,
2622
+ 'customers.deals.kanban.boardSummary.breakdownTrigger',
2623
+ 'Breakdown',
2624
+ )}
2625
+ triggerClassName="inline-flex items-center rounded-md border border-border bg-card px-2 py-0.5 text-overline font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
2626
+ />
2627
+ ) : null}
2628
+ </div>
2629
+ ) : null}
2630
+ </div>
2631
+ <div className="flex flex-wrap items-center gap-2.5">
2632
+ <SearchInput
2633
+ value={search}
2634
+ onChange={setSearch}
2635
+ placeholder={translateWithFallback(
2636
+ t,
2637
+ 'customers.deals.kanban.search.placeholder',
2638
+ 'Search deals…',
482
2639
  )}
483
- </p>
2640
+ className="w-64"
2641
+ />
2642
+ <Button variant="outline" type="button" onClick={handleCustomizeView}>
2643
+ <SlidersHorizontal className="size-4" aria-hidden="true" />
2644
+ {translateWithFallback(t, 'customers.deals.kanban.cta.customize', 'Customize view')}
2645
+ </Button>
2646
+ <Button
2647
+ variant="outline"
2648
+ type="button"
2649
+ onClick={handleAddStage}
2650
+ disabled={!selectedPipelineId}
2651
+ >
2652
+ <Plus className="size-4" aria-hidden="true" />
2653
+ {translateWithFallback(t, 'customers.deals.kanban.cta.newStage', 'New stage')}
2654
+ </Button>
2655
+ <Button asChild>
2656
+ <Link href="/backend/customers/deals/create?returnTo=/backend/customers/deals/pipeline">
2657
+ <Plus className="size-4" aria-hidden="true" />
2658
+ {translateWithFallback(t, 'customers.deals.kanban.cta.newDeal', 'New deal')}
2659
+ </Link>
2660
+ </Button>
484
2661
  </div>
485
- <div className="flex items-center gap-4">
486
- {pipelinesQuery.data && pipelinesQuery.data.length > 0 ? (
487
- <label className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
488
- <span>{translate('customers.deals.pipeline.switch.label', 'Pipeline')}</span>
489
- <Select
490
- value={selectedPipelineId || undefined}
491
- onValueChange={(value) => setSelectedPipelineId(value || null)}
492
- >
493
- <SelectTrigger className="w-auto min-w-[12rem]">
494
- <SelectValue />
495
- </SelectTrigger>
496
- <SelectContent>
497
- {pipelinesQuery.data.map((p) => (
498
- <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
499
- ))}
500
- </SelectContent>
501
- </Select>
502
- </label>
503
- ) : null}
504
- <Link
505
- href="/backend/config/customers/pipeline-stages"
506
- className="text-sm font-medium text-primary hover:underline"
2662
+ </div>
2663
+
2664
+ {pipelinesQuery.data && pipelinesQuery.data.length > 1 ? (
2665
+ <div className="flex items-center gap-2 pb-1 text-sm">
2666
+ <span className="text-muted-foreground">
2667
+ {translateWithFallback(t, 'customers.deals.pipeline.switch.label', 'Pipeline')}
2668
+ </span>
2669
+ <Select
2670
+ value={selectedPipelineId ?? undefined}
2671
+ onValueChange={(value) => setSelectedPipelineId(value || null)}
507
2672
  >
508
- {translate('customers.deals.pipeline.manageStages', 'Manage stages')}
509
- </Link>
510
- <label className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
511
- <span>{translate('customers.deals.pipeline.sort.label', 'Sort by')}</span>
512
- <Select value={sortBy} onValueChange={handleSortChange}>
513
- <SelectTrigger className="w-auto min-w-[14rem]">
514
- <SelectValue />
515
- </SelectTrigger>
516
- <SelectContent>
517
- <SelectItem value="probability">
518
- {translate('customers.deals.pipeline.sort.probability', 'Probability (high to low)')}
519
- </SelectItem>
520
- <SelectItem value="createdAt">
521
- {translate('customers.deals.pipeline.sort.createdAt', 'Created (newest first)')}
522
- </SelectItem>
523
- <SelectItem value="expectedCloseAt">
524
- {translate('customers.deals.pipeline.sort.expectedCloseAt', 'Expected close (soonest first)')}
2673
+ <SelectTrigger className="w-auto min-w-48">
2674
+ <SelectValue />
2675
+ </SelectTrigger>
2676
+ <SelectContent>
2677
+ {pipelinesQuery.data.map((pipeline) => (
2678
+ <SelectItem key={pipeline.id} value={pipeline.id}>
2679
+ {pipeline.name}
525
2680
  </SelectItem>
526
- </SelectContent>
527
- </Select>
528
- </label>
2681
+ ))}
2682
+ </SelectContent>
2683
+ </Select>
529
2684
  </div>
530
- </div>
2685
+ ) : null}
2686
+ </div>
2687
+
2688
+ <ViewTabsRow active="kanban" className="mt-4" />
531
2689
 
532
- {!selectedPipelineId ? (
533
- <EmptyState
534
- icon={<Workflow className="h-8 w-8" aria-hidden="true" />}
535
- title={translate('customers.deals.pipeline.noPipeline', 'No pipeline selected. Create a pipeline in settings.')}
536
- className="h-[50vh] w-full"
2690
+ <FilterBarRow
2691
+ leadingChips={leadingChipsNode}
2692
+ chips={filterChips}
2693
+ sortNode={sortNode}
2694
+ onChipClick={handleChipClick}
2695
+ />
2696
+
2697
+ {!selectedPipelineId ? (
2698
+ <EmptyState
2699
+ icon={<Workflow className="size-8" aria-hidden="true" />}
2700
+ title={translateWithFallback(
2701
+ t,
2702
+ 'customers.deals.pipeline.noPipeline',
2703
+ 'No pipeline selected. Create a pipeline in settings.',
2704
+ )}
2705
+ className="h-[50vh] w-full"
2706
+ />
2707
+ ) : isInitialLoading ? (
2708
+ <div className="flex h-[50vh] items-center justify-center">
2709
+ <Spinner />
2710
+ </div>
2711
+ ) : firstError ? (
2712
+ <div className="max-w-xl">
2713
+ <ErrorNotice
2714
+ message={
2715
+ firstError instanceof Error
2716
+ ? firstError.message
2717
+ : translateWithFallback(
2718
+ t,
2719
+ 'customers.deals.pipeline.loadError',
2720
+ 'Failed to load deals.',
2721
+ )
2722
+ }
537
2723
  />
538
- ) : dealsQuery.isLoading ? (
539
- <div className="flex h-[50vh] items-center justify-center">
540
- <Spinner />
541
- </div>
542
- ) : dealsQuery.isError ? (
543
- <div className="max-w-xl">
544
- <ErrorNotice
545
- message={
546
- dealsQuery.error instanceof Error
547
- ? dealsQuery.error.message
548
- : translate('customers.deals.pipeline.loadError', 'Failed to load deals.')
549
- }
550
- />
2724
+ </div>
2725
+ ) : (
2726
+ <DndContext
2727
+ sensors={sensors}
2728
+ collisionDetection={collisionDetection}
2729
+ measuring={measuringConfig}
2730
+ onDragStart={handleDragStart}
2731
+ onDragEnd={handleDragEnd}
2732
+ onDragCancel={handleDragCancel}
2733
+ >
2734
+ {/* Three-column flex: [36px gutter] [scroller flex-1] [36px gutter]
2735
+ Each gutter sized exactly to the 36px arrow button — no extra padding/gap so the
2736
+ scroller keeps as much horizontal room as possible (= more lanes fit). The buttons
2737
+ are absolute inside the gutter so they never overlap card content. */}
2738
+ <div className="flex items-stretch">
2739
+ {/*
2740
+ Lane rail reads as an elevated tab over the kanban scroller (round-2 UX item 31,
2741
+ revised): `bg-card shadow-lg` gives it a real surface and a soft drop-shadow;
2742
+ `z-10` keeps it above the scroller. `mr-2` adds an 8px gap between the rail and
2743
+ the first lane so card edges don't touch the rail (UX-designer round-3 feedback —
2744
+ the prior `-mr-2` tuck-under overlap was reverted; a small positive gap reads
2745
+ cleaner than the overlap).
2746
+
2747
+ `shadow-lg` glows in all four directions by default, but the LEFT side of the
2748
+ rail sits flush with the page chrome — without clipping, that shadow half spills
2749
+ onto the page background and reads as a halo (UX-designer feedback from Zielivia).
2750
+ `clip-path:inset(-40px -40px -40px 0)` extends the visible region 40px beyond
2751
+ top/right/bottom (so the soft drop-shadow can render in full on those three sides)
2752
+ and clips flush at the LEFT edge (the page-chrome side). Right rail mirrors with
2753
+ `inset(-40px 0 -40px -40px)`.
2754
+ */}
2755
+ <div className="relative z-10 flex w-11 mr-2 shrink-0 items-center justify-center bg-card shadow-lg [clip-path:inset(-40px_-40px_-40px_0)]">
2756
+ <IconButton
2757
+ variant="outline"
2758
+ size="lg"
2759
+ fullRadius
2760
+ onClick={handleScrollPrev}
2761
+ onPointerDown={handleHoldPrevStart}
2762
+ onPointerUp={stopContinuousScroll}
2763
+ onPointerLeave={stopContinuousScroll}
2764
+ onPointerCancel={stopContinuousScroll}
2765
+ aria-label={translateWithFallback(t, 'customers.deals.kanban.board.scrollPrev', 'Scroll to previous stage')}
2766
+ aria-disabled={scrollEdges.atStart}
2767
+ className={`absolute left-0 top-28 flex size-9 items-center justify-center rounded-full border border-border bg-card text-foreground shadow-lg transition-opacity hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
2768
+ scrollEdges.atStart ? 'opacity-30' : ''
2769
+ }`}
2770
+ >
2771
+ <ChevronLeft className="size-5" aria-hidden="true" />
2772
+ </IconButton>
551
2773
  </div>
552
- ) : (
553
- <div className="flex flex-col gap-3">
554
- {total > deals.length ? (
555
- <div className="rounded-md border border-border bg-muted/30 px-4 py-2 text-sm text-muted-foreground">
556
- {translate(
557
- 'customers.deals.pipeline.limitNotice',
558
- 'Showing the first {count} deals. Refine your filters to see more.',
559
- { count: deals.length },
2774
+ <div
2775
+ ref={setBoardScroller}
2776
+ data-kanban-scroller
2777
+ className={`flex min-w-0 flex-1 gap-3.5 overflow-x-auto pb-6 ${
2778
+ activeDragDealId ? 'cursor-grabbing select-none' : ''
2779
+ }`}
2780
+ >
2781
+ {lanes.length === 0 ? (
2782
+ <EmptyState
2783
+ icon={<Layers className="size-8" aria-hidden="true" />}
2784
+ title={translateWithFallback(
2785
+ t,
2786
+ 'customers.deals.pipeline.noStages',
2787
+ 'Define pipeline stages to start tracking deals.',
560
2788
  )}
561
- </div>
562
- ) : null}
563
-
564
- <div className="flex flex-col gap-4 pb-6 md:flex-row md:overflow-x-auto">
565
- {stages.length === 0 ? (
566
- <EmptyState
567
- icon={<Layers className="h-8 w-8" aria-hidden="true" />}
568
- title={translate('customers.deals.pipeline.noStages', 'Define pipeline stages to start tracking deals.')}
569
- className="h-[50vh] w-full"
570
- />
571
- ) : (
572
- stages.map((stage) => {
573
- const stageKey = stage.value ?? null
574
- const laneDeals = groupedDeals.get(stageKey) ?? []
575
- const sortedLaneDeals = sortDeals(laneDeals, sortBy)
576
- const isActive = activeLane === stage.id
2789
+ className="h-[50vh] w-full"
2790
+ />
2791
+ ) : (
2792
+ <>
2793
+ {lanes.map((stage) => {
2794
+ const laneDeals = sortedDealsByStage.get(stage.id) ?? EMPTY_DEAL_ARRAY
577
2795
  return (
578
- <div
2796
+ <Lane
579
2797
  key={stage.id}
580
- className={`flex min-h-[60vh] w-full flex-1 flex-col overflow-hidden rounded-lg border border-border bg-card shadow-sm transition-all md:w-72 md:flex-none ${
581
- isActive ? 'ring-2 ring-ring/40' : ''
582
- }`}
583
- onDragOver={handleDragOver(stage.id)}
584
- onDrop={handleDrop(stage)}
585
- >
586
- {renderLaneHeader(stage, laneDeals.length)}
587
- <div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-3">
588
- {sortedLaneDeals.length === 0 ? (
589
- <EmptyState
590
- size="sm"
591
- icon={<MoveRight className="h-6 w-6" aria-hidden="true" />}
592
- title={translate('customers.deals.pipeline.emptyLane', 'No deals in this stage yet.')}
593
- />
594
- ) : (
595
- sortedLaneDeals.map((deal) => {
596
- const isDragging = draggingId === deal.id
597
- || (pendingDealId === deal.id && updateStageMutation.isPending)
598
- const valueLabel = formatCurrency(
599
- deal.valueAmount,
600
- deal.valueCurrency,
601
- translate('customers.deals.list.noValue', 'No value assigned'),
602
- )
603
- const probabilityLabel = formatProbability(
604
- deal.probability,
605
- translate('customers.deals.pipeline.noProbability', 'N/A'),
606
- )
607
- const expectedLabel = deal.expectedCloseAt
608
- ? dateFormatter.format(new Date(deal.expectedCloseAt))
609
- : translate('customers.deals.pipeline.noExpectedClose', 'No date')
610
- return (
611
- <div
612
- key={deal.id}
613
- className={`group flex cursor-grab flex-col gap-2 rounded-md border border-border bg-background p-4 shadow-xs transition ${
614
- isDragging ? 'opacity-50' : 'hover:shadow-sm'
615
- }`}
616
- draggable
617
- onDragStart={(event) => {
618
- event.dataTransfer.effectAllowed = 'move'
619
- event.dataTransfer.setData('text/plain', deal.id)
620
- handleDragStart(deal.id)
621
- }}
622
- onDragEnd={handleDragEnd}
623
- >
624
- <div className="flex items-start justify-between gap-2">
625
- <div className="flex flex-col">
626
- <span className="line-clamp-2 text-sm font-medium text-foreground">
627
- {deal.title}
628
- </span>
629
- {deal.status ? (
630
- <span className="text-xs uppercase tracking-wide text-muted-foreground">
631
- {deal.status}
632
- </span>
633
- ) : null}
634
- </div>
635
- {pendingDealId === deal.id && updateStageMutation.isPending ? (
636
- <Spinner className="size-4" />
637
- ) : null}
638
- </div>
639
- <div className="flex flex-col gap-1 text-xs text-muted-foreground">
640
- <div className="flex items-center justify-between gap-2">
641
- <span>{translate('customers.deals.pipeline.card.value', 'Value')}</span>
642
- <span className="font-medium text-foreground">{valueLabel}</span>
643
- </div>
644
- <div className="flex items-center justify-between gap-2">
645
- <span>{translate('customers.deals.pipeline.card.probability', 'Probability')}</span>
646
- <span className="font-medium text-foreground">{probabilityLabel}</span>
647
- </div>
648
- <div className="flex items-center justify-between gap-2">
649
- <span>{translate('customers.deals.pipeline.card.expectedClose', 'Expected close')}</span>
650
- <span className="font-medium text-foreground">{expectedLabel}</span>
651
- </div>
652
- </div>
653
- <div className="mt-1 flex flex-wrap gap-2 text-xs">
654
- <Link
655
- href={`/backend/customers/deals/${deal.id}`}
656
- className="font-medium text-primary hover:underline"
657
- draggable={false}
658
- onClick={handleActionClick}
659
- >
660
- {translate('customers.deals.pipeline.actions.openDeal', 'Open deal')}
661
- </Link>
662
- </div>
663
- {deal.people.length ? (
664
- <div className="flex flex-wrap gap-2">
665
- {deal.people.map((person) => (
666
- <Link
667
- key={person.id}
668
- className="rounded-full bg-primary/5 px-3 py-1 text-xs text-primary transition-colors hover:bg-primary/10"
669
- href={`/backend/customers/people-v2/${person.id}`}
670
- draggable={false}
671
- onClick={handleActionClick}
672
- >
673
- {person.label}
674
- </Link>
675
- ))}
676
- </div>
677
- ) : null}
678
- {deal.companies.length ? (
679
- <div className="flex flex-wrap gap-2">
680
- {deal.companies.map((company) => (
681
- <Link
682
- key={company.id}
683
- className="rounded-full bg-secondary/10 px-3 py-1 text-xs text-secondary-foreground transition-colors hover:bg-secondary/20"
684
- href={`/backend/customers/companies-v2/${company.id}`}
685
- draggable={false}
686
- onClick={handleActionClick}
687
- >
688
- {company.label}
689
- </Link>
690
- ))}
691
- </div>
692
- ) : null}
693
- </div>
694
- )
695
- })
696
- )}
697
- </div>
698
- </div>
2798
+ stage={stage}
2799
+ deals={laneDeals}
2800
+ aggregate={aggregateByStage.get(stage.id) ?? null}
2801
+ selectedDealIds={selectedDealIds}
2802
+ buildMenuItems={buildMenuItems}
2803
+ activeDragDealId={activeDragDealId}
2804
+ isLoadingMore={!!loadingMoreByStage[stage.id]}
2805
+ width={laneWidths[stage.id] ?? null}
2806
+ onToggleSelect={handleToggleSelect}
2807
+ onComposeActivity={handleComposeActivity}
2808
+ onOpenDetail={handleOpenDetail}
2809
+ onQuickAddClick={handleQuickAdd}
2810
+ onLoadMore={handleLoadMoreInLane}
2811
+ onResize={handleLaneResize}
2812
+ onResetWidth={handleResetLaneWidth}
2813
+ />
699
2814
  )
700
- })
701
- )}
702
- </div>
2815
+ })}
2816
+ {/*
2817
+ Trailing "Add stage" tile (round-2 UX review item 30, revised). Coexists
2818
+ with the page-header toolbar button by explicit design: the toolbar button
2819
+ is the always-visible primary CTA; this tile is the in-context affordance
2820
+ at the end of the stage row that operators have used to map onto from
2821
+ Trello/Asana muscle memory.
2822
+ */}
2823
+ <AddStageLane onClick={handleAddStage} />
2824
+ </>
2825
+ )}
703
2826
  </div>
704
- )}
705
- </div>
2827
+ {/* Mirror of the left rail; `ml-3.5` adds a 14px gap between the last lane and the
2828
+ right rail. The right gap is intentionally wider than the left (`mr-2` = 8px)
2829
+ because the trailing tile here is `AddStageLane` — its dashed border reads more
2830
+ subtly than a regular lane's saturated color bar, so the same 8px on the right
2831
+ perceptually feels like a "tucked under" overlap (round-3 UX-designer feedback).
2832
+ `ml-3.5` matches the scroller's inter-lane `gap-3.5` so the visual rhythm stays
2833
+ consistent. `clip-path:inset(-40px 0 -40px -40px)` is the right-rail mirror of the
2834
+ left rail's clip — keeps the shadow on top/left/bottom (40px extend) and clips
2835
+ flush at the RIGHT edge so it doesn't spill onto the page background past the
2836
+ page chrome. */}
2837
+ <div className="relative z-10 flex w-11 ml-3.5 shrink-0 items-center justify-center bg-card shadow-lg [clip-path:inset(-40px_0_-40px_-40px)]">
2838
+ <IconButton
2839
+ variant="outline"
2840
+ size="lg"
2841
+ fullRadius
2842
+ onClick={handleScrollNext}
2843
+ onPointerDown={handleHoldNextStart}
2844
+ onPointerUp={stopContinuousScroll}
2845
+ onPointerLeave={stopContinuousScroll}
2846
+ onPointerCancel={stopContinuousScroll}
2847
+ aria-label={translateWithFallback(t, 'customers.deals.kanban.board.scrollNext', 'Scroll to next stage')}
2848
+ aria-disabled={scrollEdges.atEnd}
2849
+ className={`absolute right-0 top-28 flex size-9 items-center justify-center rounded-full border border-border bg-card text-foreground shadow-lg transition-opacity hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
2850
+ scrollEdges.atEnd ? 'opacity-30' : ''
2851
+ }`}
2852
+ >
2853
+ <ChevronRight className="size-5" aria-hidden="true" />
2854
+ </IconButton>
2855
+ </div>
2856
+ </div>
2857
+ <DragOverlay dropAnimation={null}>
2858
+ {activeDragDeal ? (
2859
+ <div className="relative">
2860
+ <div className={`pointer-events-none ${LANE_WIDTH_CLASS} rotate-2 cursor-grabbing select-none rounded-lg border border-border bg-card px-4 py-3.5 shadow-xl ring-2 ring-accent-indigo/40`}>
2861
+ <div className="flex flex-col gap-2">
2862
+ <h3 className="line-clamp-2 text-base font-semibold leading-normal text-foreground">
2863
+ {activeDragDeal.title}
2864
+ </h3>
2865
+ {typeof activeDragDeal.valueAmount === 'number' ? (
2866
+ <div className="flex items-baseline gap-1.5">
2867
+ <span className="text-lg font-bold leading-normal text-foreground">
2868
+ {new Intl.NumberFormat(undefined, {
2869
+ style: 'decimal',
2870
+ maximumFractionDigits: 0,
2871
+ useGrouping: true,
2872
+ }).format(activeDragDeal.valueAmount)}
2873
+ </span>
2874
+ {activeDragDeal.valueCurrency ? (
2875
+ <span className="text-sm font-semibold leading-normal text-muted-foreground">
2876
+ {activeDragDeal.valueCurrency.toUpperCase()}
2877
+ </span>
2878
+ ) : null}
2879
+ </div>
2880
+ ) : null}
2881
+ {activeDragDeal.primaryCompany ? (
2882
+ <span className="inline-flex w-fit max-w-full items-center gap-1.5 overflow-hidden rounded-md bg-muted px-2.5 py-1 text-sm font-semibold leading-normal text-foreground">
2883
+ <span className="truncate">{activeDragDeal.primaryCompany.label}</span>
2884
+ </span>
2885
+ ) : null}
2886
+ </div>
2887
+ </div>
2888
+ {bulkDragActive ? (
2889
+ // +N badge tells the operator how many cards are travelling with the dragged
2890
+ // one — round-2 UX review item 32. N excludes the dragged card itself, so a
2891
+ // selection of 5 shows "+4" floating above the dragged surface.
2892
+ <span
2893
+ aria-label={translateWithFallback(
2894
+ t,
2895
+ 'customers.deals.kanban.bulk.dragOverlayBadge',
2896
+ '{count} more selected',
2897
+ { count: selectedDealIds.size - 1 },
2898
+ )}
2899
+ className="pointer-events-none absolute -right-3 -top-3 inline-flex h-7 min-w-7 rotate-2 items-center justify-center rounded-full border-2 border-card bg-foreground px-2 text-xs font-bold tabular-nums text-background shadow-lg"
2900
+ >
2901
+ +{selectedDealIds.size - 1}
2902
+ </span>
2903
+ ) : null}
2904
+ </div>
2905
+ ) : null}
2906
+ </DragOverlay>
2907
+ </DndContext>
2908
+ )}
2909
+
2910
+ {selectedPipelineId && !isInitialLoading ? (
2911
+ <div className="flex flex-col gap-1 border-t border-border pt-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
2912
+ <span>{footerCount}</span>
2913
+ <span>{dragHint}</span>
2914
+ {pendingDealId ? <Spinner className="size-3" /> : null}
2915
+ </div>
2916
+ ) : null}
706
2917
  </PageBody>
2918
+
2919
+ <QuickDealDialog
2920
+ open={!!quickDealContext}
2921
+ context={quickDealContext}
2922
+ onClose={() => setQuickDealContext(null)}
2923
+ onCreated={handleDialogCreated}
2924
+ currentUserId={currentUserId || undefined}
2925
+ currentUserLabel={currentUserLabel}
2926
+ companies={companiesQuery.data ?? []}
2927
+ currencies={
2928
+ currencyDictionary.data
2929
+ ? currencyDictionary.data.entries.map((entry) => ({
2930
+ code: entry.value,
2931
+ label: entry.label,
2932
+ isBase: boardSummary?.baseCurrencyCode
2933
+ ? entry.value === boardSummary.baseCurrencyCode
2934
+ : false,
2935
+ }))
2936
+ : undefined
2937
+ }
2938
+ />
2939
+ <AddStageDialog
2940
+ open={!!addStageContext}
2941
+ context={addStageContext}
2942
+ onClose={() => setAddStageContext(null)}
2943
+ onCreated={handleDialogCreated}
2944
+ />
2945
+ <CustomizeViewDialog
2946
+ open={customizeOpen}
2947
+ resizedLanesCount={Object.keys(laneWidths).length}
2948
+ onClose={() => setCustomizeOpen(false)}
2949
+ onResetToDefault={handleResetView}
2950
+ onResetColumnWidths={handleResetAllLaneWidths}
2951
+ />
2952
+ <ActivityComposerDialog
2953
+ open={!!activityContext}
2954
+ context={activityContext}
2955
+ onClose={() => setActivityContext(null)}
2956
+ onCreated={handleDialogCreated}
2957
+ />
2958
+ <ChangeStageDialog
2959
+ open={changeStageOpen}
2960
+ selectedCount={bulkSelectionSummary.count}
2961
+ pipelineName={activePipelineName}
2962
+ stages={stageOptions}
2963
+ isSubmitting={isBulkMutating}
2964
+ onClose={() => setChangeStageOpen(false)}
2965
+ onConfirm={(stageId) => void handleBulkChangeStage(stageId)}
2966
+ />
2967
+ <ChangeStageDialog
2968
+ open={!!singleMoveStageContext}
2969
+ selectedCount={1}
2970
+ pipelineName={activePipelineName}
2971
+ stages={stageOptions}
2972
+ isSubmitting={false}
2973
+ onClose={() => setSingleMoveStageContext(null)}
2974
+ onConfirm={(stageId) => {
2975
+ if (singleMoveStageContext) {
2976
+ moveDealToStage(singleMoveStageContext.dealId, stageId)
2977
+ }
2978
+ setSingleMoveStageContext(null)
2979
+ }}
2980
+ />
2981
+ <ChangeOwnerDialog
2982
+ open={changeOwnerOpen}
2983
+ selectedCount={bulkSelectionSummary.count}
2984
+ isSubmitting={isBulkMutating}
2985
+ onClose={() => setChangeOwnerOpen(false)}
2986
+ onConfirm={(userId) => void handleBulkChangeOwner(userId)}
2987
+ />
2988
+ <BulkActionsBar
2989
+ count={bulkSelectionSummary.count}
2990
+ totalLabel={bulkSelectionSummary.totalLabel}
2991
+ onChangeStage={() => setChangeStageOpen(true)}
2992
+ onChangeOwner={() => setChangeOwnerOpen(true)}
2993
+ onExportCsv={handleBulkExport}
2994
+ onDelete={() => void handleBulkDelete()}
2995
+ onClear={handleBulkClear}
2996
+ />
2997
+ {ConfirmDialogElement}
707
2998
  </Page>
708
2999
  )
709
3000
  }