@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
@@ -0,0 +1,378 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { useDroppable } from '@dnd-kit/core'
5
+ import { EmptyState } from '@open-mercato/ui/primitives/empty-state'
6
+ import type { RowActionItem } from '@open-mercato/ui/backend/RowActions'
7
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
8
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
9
+ import type { FilterOptionTone } from '@open-mercato/shared/lib/query/advanced-filter'
10
+ import { DealCard, type DealCardData } from './DealCard'
11
+ import { DashedTileButton } from './DashedTileButton'
12
+ import { LANE_WIDTH_PX } from './constants'
13
+ import { LaneCurrencyBreakdown } from './LaneCurrencyBreakdown'
14
+ import { LaneResizeHandle } from './LaneResizeHandle'
15
+
16
+ export type LaneStage = {
17
+ id: string
18
+ label: string
19
+ tone: FilterOptionTone | null
20
+ }
21
+
22
+ export type LaneAggregateProp = {
23
+ count: number
24
+ totalInBaseCurrency: number
25
+ byCurrency: Array<{ currency: string; total: number; count: number }>
26
+ baseCurrencyCode: string | null
27
+ /** `true` when every currency in `byCurrency` was convertible to the base currency. */
28
+ convertedAll: boolean
29
+ /** Currencies present in `byCurrency` that lack an FX rate to base — excluded from totals. */
30
+ missingRateCurrencies: string[]
31
+ }
32
+
33
+ type LaneProps = {
34
+ stage: LaneStage
35
+ deals: DealCardData[]
36
+ /** Server-side aggregate for accurate counts/totals across all deals in this stage, not just loaded ones */
37
+ aggregate?: LaneAggregateProp | null
38
+ selectedDealIds: Set<string>
39
+ buildMenuItems: (deal: DealCardData) => RowActionItem[]
40
+ activeDragDealId?: string | null
41
+ isLoadingMore?: boolean
42
+ /** Pixel width override (when the user has resized the column). Falls back to the default class. */
43
+ width?: number | null
44
+ onToggleSelect: (dealId: string) => void
45
+ onComposeActivity: (dealId: string, type: 'call' | 'email' | 'note') => void
46
+ onOpenDetail: (dealId: string) => void
47
+ onQuickAddClick: (stageId: string) => void
48
+ onLoadMore?: (stageId: string) => void
49
+ /** Drag-resize callbacks. When provided, a vertical drag handle is rendered on the lane edge. */
50
+ onResize?: (stageId: string, deltaPx: number) => void
51
+ onResizeEnd?: (stageId: string) => void
52
+ onResetWidth?: (stageId: string) => void
53
+ }
54
+
55
+ // 4px color bar tone — uses saturated icon tokens so the bar is visibly colored, not pale
56
+ const ACCENT_TONE_CLASS: Record<FilterOptionTone, string> = {
57
+ success: 'bg-status-success-icon',
58
+ error: 'bg-status-error-icon',
59
+ warning: 'bg-status-warning-icon',
60
+ info: 'bg-status-info-icon',
61
+ neutral: 'bg-status-neutral-icon',
62
+ brand: 'bg-brand-violet',
63
+ pink: 'bg-status-pink-icon',
64
+ }
65
+
66
+ // Count pill bg uses very-light status background, text uses status text color
67
+ const COUNT_BADGE_TONE_CLASS: Record<FilterOptionTone, string> = {
68
+ success: 'bg-status-success-bg text-status-success-text',
69
+ error: 'bg-status-error-bg text-status-error-text',
70
+ warning: 'bg-status-warning-bg text-status-warning-text',
71
+ info: 'bg-status-info-bg text-status-info-text',
72
+ neutral: 'bg-status-neutral-bg text-status-neutral-text',
73
+ brand: 'bg-brand-violet/14 text-brand-violet',
74
+ pink: 'bg-status-pink-bg text-status-pink-text',
75
+ }
76
+
77
+ function getAccentClass(tone: FilterOptionTone | null): string {
78
+ if (tone && tone in ACCENT_TONE_CLASS) return ACCENT_TONE_CLASS[tone]
79
+ return 'bg-border'
80
+ }
81
+
82
+ function getCountBadgeClass(tone: FilterOptionTone | null): string {
83
+ if (tone && tone in COUNT_BADGE_TONE_CLASS) return COUNT_BADGE_TONE_CLASS[tone]
84
+ return 'bg-muted text-muted-foreground'
85
+ }
86
+
87
+ function compactCurrency(amount: number): string {
88
+ if (amount >= 1_000_000) {
89
+ const value = amount / 1_000_000
90
+ return value >= 100 ? `${Math.round(value)}M` : `${value.toFixed(1).replace(/\.0$/, '')}M`
91
+ }
92
+ if (amount >= 1_000) {
93
+ const value = amount / 1_000
94
+ return value >= 100 ? `${Math.round(value)}k` : `${value.toFixed(1).replace(/\.0$/, '')}k`
95
+ }
96
+ return String(Math.round(amount))
97
+ }
98
+
99
+ type CurrencyRow = { currency: string; total: number; count: number }
100
+
101
+ type LaneStats = {
102
+ count: number
103
+ /** Headline amount and the currency it is denominated in (largest single currency, raw, never converted). */
104
+ primaryAmount: number
105
+ primaryCurrency: string | null
106
+ /** Per-currency rows sorted desc by total — the popover renders these one per line. */
107
+ rows: CurrencyRow[]
108
+ /** Sum of `rows` converted to base currency (zero when no base is configured / nothing converted). */
109
+ totalInBaseCurrency: number
110
+ baseCurrencyCode: string | null
111
+ convertedAll: boolean
112
+ missingRateCurrencies: string[]
113
+ }
114
+
115
+ /**
116
+ * Build display stats.
117
+ * - If a server aggregate is provided, prefer it (covers all deals, converts to tenant base currency).
118
+ * - Otherwise fall back to summing loaded deals (used before aggregate query loads).
119
+ *
120
+ * The headline currency on the lane header is intentionally the LARGEST raw currency
121
+ * (not the base-currency converted total) because the converted total can be misleading
122
+ * when FX rates are missing — operators see a smaller number than the headline suggests.
123
+ * The base-currency converted total is reserved for the breakdown popover footer, where
124
+ * a partial-conversion indicator can disclose what's excluded.
125
+ */
126
+ function buildLaneStats(deals: DealCardData[], aggregate?: LaneAggregateProp | null): LaneStats {
127
+ if (aggregate) {
128
+ const rows = aggregate.byCurrency
129
+ // Pick the single currency with the largest raw total to surface in the header. This
130
+ // is always honest: the operator sees the actual largest cluster of value rather than
131
+ // a synthetic base-currency total that may be partial.
132
+ const headline = rows.length > 0 ? rows[0] : null
133
+ return {
134
+ count: aggregate.count,
135
+ primaryAmount: headline ? headline.total : 0,
136
+ primaryCurrency: headline ? headline.currency : null,
137
+ rows,
138
+ totalInBaseCurrency: aggregate.totalInBaseCurrency,
139
+ baseCurrencyCode: aggregate.baseCurrencyCode,
140
+ convertedAll: aggregate.convertedAll,
141
+ missingRateCurrencies: aggregate.missingRateCurrencies,
142
+ }
143
+ }
144
+ const totalsByCurrency = new Map<string, { total: number; count: number }>()
145
+ for (const deal of deals) {
146
+ if (typeof deal.valueAmount !== 'number' || !Number.isFinite(deal.valueAmount)) continue
147
+ const code = deal.valueCurrency && deal.valueCurrency.length === 3
148
+ ? deal.valueCurrency.toUpperCase()
149
+ : 'USD'
150
+ const entry = totalsByCurrency.get(code) ?? { total: 0, count: 0 }
151
+ entry.total += deal.valueAmount
152
+ entry.count += 1
153
+ totalsByCurrency.set(code, entry)
154
+ }
155
+ const rows: CurrencyRow[] = Array.from(totalsByCurrency.entries())
156
+ .map(([currency, value]) => ({ currency, total: value.total, count: value.count }))
157
+ .sort((a, b) => b.total - a.total)
158
+ if (rows.length === 0) {
159
+ return {
160
+ count: deals.length,
161
+ primaryAmount: 0,
162
+ primaryCurrency: null,
163
+ rows,
164
+ totalInBaseCurrency: 0,
165
+ baseCurrencyCode: null,
166
+ convertedAll: true,
167
+ missingRateCurrencies: [],
168
+ }
169
+ }
170
+ return {
171
+ count: deals.length,
172
+ primaryAmount: rows[0].total,
173
+ primaryCurrency: rows[0].currency,
174
+ rows,
175
+ totalInBaseCurrency: 0,
176
+ baseCurrencyCode: null,
177
+ // We don't have rates client-side; mark as "not converted" only when there are
178
+ // multiple currencies so the popover can show a "configure FX rates" hint.
179
+ convertedAll: rows.length === 1,
180
+ missingRateCurrencies: rows.length > 1 ? rows.slice(1).map((row) => row.currency) : [],
181
+ }
182
+ }
183
+
184
+ function LaneImpl({
185
+ stage,
186
+ deals,
187
+ aggregate,
188
+ selectedDealIds,
189
+ buildMenuItems,
190
+ activeDragDealId,
191
+ isLoadingMore = false,
192
+ width,
193
+ onToggleSelect,
194
+ onComposeActivity,
195
+ onOpenDetail,
196
+ onQuickAddClick,
197
+ onLoadMore,
198
+ onResize,
199
+ onResizeEnd,
200
+ onResetWidth,
201
+ }: LaneProps): React.ReactElement {
202
+ const t = useT()
203
+ const { setNodeRef, isOver } = useDroppable({
204
+ id: `lane:${stage.id}`,
205
+ data: { type: 'lane', stageId: stage.id },
206
+ })
207
+ const isDragActive = !!activeDragDealId
208
+ const sourceLane = isDragActive && deals.some((deal) => deal.id === activeDragDealId)
209
+ // Highlight drop zone when:
210
+ // - the lane has the pointer over it during a drag, OR
211
+ // - any drag is active and this lane is not the source (so all candidate targets are clearly tagged)
212
+ const showDropHighlight = isOver || (isDragActive && !sourceLane)
213
+ const stats = React.useMemo(() => buildLaneStats(deals, aggregate), [deals, aggregate])
214
+ // Pull boolean out of the Set so each DealCard receives a stable primitive — that lets
215
+ // React.memo on DealCard skip re-renders when the Set reference changes but its emptiness
216
+ // does not. Flipping false→true (first selection) or true→false (last cleared) forces a
217
+ // full pass over the lane's cards so their checkbox visibility updates atomically.
218
+ const bulkSelectionActive = selectedDealIds.size > 0
219
+ const totalLabel = stats.primaryAmount > 0 && stats.primaryCurrency
220
+ ? `${compactCurrency(stats.primaryAmount)} ${stats.primaryCurrency}`
221
+ : null
222
+ const visibleCount = deals.length
223
+ const totalCount = stats.count
224
+ const hasMoreDeals = totalCount > visibleCount
225
+ const accentClass = getAccentClass(stage.tone)
226
+ const countBadgeClass = getCountBadgeClass(stage.tone)
227
+
228
+ const handleQuickAdd = React.useCallback(() => {
229
+ onQuickAddClick(stage.id)
230
+ }, [onQuickAddClick, stage.id])
231
+
232
+ return (
233
+ <div
234
+ className="relative flex flex-none flex-col gap-3"
235
+ style={{ width: typeof width === 'number' && Number.isFinite(width) ? `${width}px` : `${LANE_WIDTH_PX}px` }}
236
+ >
237
+ <div className="flex flex-col gap-2.5 overflow-clip rounded-lg bg-muted/40 px-4 py-3.5">
238
+ <div className={`h-1.5 w-full rounded-sm ${accentClass}`} aria-hidden="true" />
239
+ <div className="flex items-center justify-between gap-2">
240
+ <div className="flex min-w-0 items-center gap-2.5">
241
+ <span className="truncate text-sm font-bold uppercase leading-normal text-foreground">
242
+ {stage.label}
243
+ </span>
244
+ <span
245
+ className={`inline-flex shrink-0 items-center rounded-full px-2 py-1 text-xs font-bold leading-normal ${countBadgeClass}`}
246
+ title={
247
+ hasMoreDeals
248
+ ? translateWithFallback(
249
+ t,
250
+ 'customers.deals.kanban.lane.aria.partialCount',
251
+ 'Showing {visible} of {total}',
252
+ { visible: visibleCount, total: totalCount },
253
+ )
254
+ : undefined
255
+ }
256
+ >
257
+ {totalCount}
258
+ </span>
259
+ </div>
260
+ <div className="flex shrink-0 items-center gap-2">
261
+ {totalLabel ? (
262
+ <span className="text-sm font-bold leading-normal text-foreground">
263
+ {totalLabel}
264
+ </span>
265
+ ) : null}
266
+ {stats.rows.length > 1 ? (
267
+ <LaneCurrencyBreakdown
268
+ rows={stats.rows}
269
+ baseCurrencyCode={stats.baseCurrencyCode}
270
+ totalInBaseCurrency={stats.totalInBaseCurrency}
271
+ convertedAll={stats.convertedAll}
272
+ missingRateCurrencies={stats.missingRateCurrencies}
273
+ triggerLabel={`+${stats.rows.length - 1}`}
274
+ headingLabel={stage.label.toUpperCase()}
275
+ headingCount={totalCount}
276
+ />
277
+ ) : null}
278
+ </div>
279
+ </div>
280
+ </div>
281
+
282
+ <DashedTileButton
283
+ onClick={handleQuickAdd}
284
+ className="h-11"
285
+ ariaLabel={translateWithFallback(
286
+ t,
287
+ 'customers.deals.kanban.lane.aria.quickAdd',
288
+ 'Quickly add a deal to {stage}',
289
+ { stage: stage.label },
290
+ )}
291
+ >
292
+ <span className="text-base font-bold leading-none">+</span>
293
+ <span>{translateWithFallback(t, 'customers.deals.kanban.cta.quickDeal', 'Quick deal')}</span>
294
+ </DashedTileButton>
295
+
296
+ <div
297
+ ref={setNodeRef}
298
+ className={`flex min-h-[40vh] flex-col gap-3 rounded-lg p-1.5 ${
299
+ isOver
300
+ ? 'bg-accent-indigo/10 outline-dashed outline-2 outline-accent-indigo -outline-offset-[2px]'
301
+ : showDropHighlight
302
+ ? 'bg-muted/40 outline-dashed outline-1 outline-muted-foreground/40 -outline-offset-[2px]'
303
+ : ''
304
+ }`}
305
+ >
306
+ {deals.length === 0 ? (
307
+ <EmptyState
308
+ size="sm"
309
+ title={translateWithFallback(
310
+ t,
311
+ 'customers.deals.kanban.lane.empty',
312
+ 'No deals in this stage yet.',
313
+ )}
314
+ />
315
+ ) : (
316
+ deals.map((deal) => (
317
+ <DealCard
318
+ key={deal.id}
319
+ deal={deal}
320
+ selected={selectedDealIds.has(deal.id)}
321
+ bulkSelectionActive={bulkSelectionActive}
322
+ buildMenuItems={buildMenuItems}
323
+ isActiveDrag={activeDragDealId === deal.id}
324
+ onToggleSelect={onToggleSelect}
325
+ onComposeActivity={onComposeActivity}
326
+ onOpenDetail={onOpenDetail}
327
+ />
328
+ ))
329
+ )}
330
+ {hasMoreDeals && onLoadMore ? (
331
+ <DashedTileButton
332
+ onClick={() => onLoadMore(stage.id)}
333
+ disabled={isLoadingMore}
334
+ className="mt-0.75 px-3 py-2.5 text-muted-foreground"
335
+ ariaLabel={translateWithFallback(
336
+ t,
337
+ 'customers.deals.kanban.lane.aria.loadMore',
338
+ 'Load more deals in {stage}',
339
+ { stage: stage.label },
340
+ )}
341
+ >
342
+ {isLoadingMore
343
+ ? translateWithFallback(t, 'customers.deals.kanban.lane.loadingMore', 'Loading…')
344
+ : translateWithFallback(
345
+ t,
346
+ 'customers.deals.kanban.lane.loadMore',
347
+ 'Show more ({remaining})',
348
+ { remaining: totalCount - visibleCount },
349
+ )}
350
+ </DashedTileButton>
351
+ ) : null}
352
+ </div>
353
+ {onResize ? (
354
+ <LaneResizeHandle
355
+ onResize={(delta) => onResize(stage.id, delta)}
356
+ onResizeEnd={() => onResizeEnd?.(stage.id)}
357
+ onReset={() => onResetWidth?.(stage.id)}
358
+ />
359
+ ) : null}
360
+ </div>
361
+ )
362
+ }
363
+
364
+ // Memoize the lane so changes in a different lane (or in the active drag) don't re-render us.
365
+ // Page passes booleans (isDragActive) instead of activeDragDealId so most lanes can skip re-rendering.
366
+ export const Lane = React.memo(LaneImpl, (prev, next) => {
367
+ if (prev.stage !== next.stage) return false
368
+ if (prev.deals !== next.deals) return false
369
+ if (prev.aggregate !== next.aggregate) return false
370
+ if (prev.selectedDealIds !== next.selectedDealIds) return false
371
+ if (prev.buildMenuItems !== next.buildMenuItems) return false
372
+ if (prev.activeDragDealId !== next.activeDragDealId) return false
373
+ if (prev.isLoadingMore !== next.isLoadingMore) return false
374
+ if (prev.width !== next.width) return false
375
+ return true
376
+ })
377
+
378
+ export default Lane
@@ -0,0 +1,192 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import {
5
+ Popover,
6
+ PopoverContent,
7
+ PopoverTrigger,
8
+ } from '@open-mercato/ui/primitives/popover'
9
+ import { Button } from '@open-mercato/ui/primitives/button'
10
+ import { IconButton } from '@open-mercato/ui/primitives/icon-button'
11
+ import { X } from 'lucide-react'
12
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
13
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
14
+ import { CurrencyBreakdownTable, type CurrencyBreakdownRow } from './CurrencyBreakdownTable'
15
+
16
+ type LaneCurrencyBreakdownProps = {
17
+ /** Per-currency breakdown rows (already sorted desc by total in the API response). */
18
+ rows: CurrencyBreakdownRow[]
19
+ /** Tenant base currency code, or `null` when none is configured. */
20
+ baseCurrencyCode: string | null
21
+ /** Sum of `rows` converted to the base currency (only rows with a usable FX rate are included). */
22
+ totalInBaseCurrency: number
23
+ /** Whether every row in `rows` was either already base-currency or had an FX rate. */
24
+ convertedAll: boolean
25
+ /** Currencies in `rows` that have no FX rate to base — they are excluded from `totalInBaseCurrency`. */
26
+ missingRateCurrencies: string[]
27
+ /** Visible chip label shown in the lane header (e.g. "+3"). */
28
+ triggerLabel: string
29
+ /** Optional className for the trigger chip. */
30
+ triggerClassName?: string
31
+ /**
32
+ * Heading shown above the table (e.g. lane stage label or `"PIPELINE"` for the
33
+ * board-level breakdown). Rendered in overline style above the headline total.
34
+ */
35
+ headingLabel: string
36
+ /** Total deal count shown next to the heading label (e.g. `"49 deals"`). */
37
+ headingCount: number
38
+ }
39
+
40
+ /**
41
+ * Read-only per-currency breakdown popover.
42
+ *
43
+ * Reused by:
44
+ * - lane header `+N` chip (lane-scoped)
45
+ * - page-level Breakdown button (pipeline-scoped)
46
+ *
47
+ * Visual design from SPEC-048 Figma node 1251:610 — bordered white header bar with bold title +
48
+ * close button, overline heading + large total amount, currency table with thin row dividers,
49
+ * BASE pill on the base-currency row, and a muted "NBP mid rate · {date}" footer credit.
50
+ *
51
+ * The body (column header + currency rows + BASE pill + footer credit) lives in
52
+ * `CurrencyBreakdownTable` so the filter variant (`CurrencyFilterPopover`) can reuse the same
53
+ * row composition without duplicating styling.
54
+ */
55
+ export function LaneCurrencyBreakdown({
56
+ rows,
57
+ baseCurrencyCode,
58
+ totalInBaseCurrency,
59
+ convertedAll,
60
+ missingRateCurrencies,
61
+ triggerLabel,
62
+ triggerClassName,
63
+ headingLabel,
64
+ headingCount,
65
+ }: LaneCurrencyBreakdownProps): React.ReactElement {
66
+ const t = useT()
67
+ const [open, setOpen] = React.useState(false)
68
+
69
+ const closeLabel = translateWithFallback(t, 'customers.deals.kanban.filter.close', 'Close')
70
+ const titleLabel = translateWithFallback(
71
+ t,
72
+ 'customers.deals.kanban.currencyBreakdown.title',
73
+ 'Lane breakdown',
74
+ )
75
+ const headingCountLabel = translateWithFallback(
76
+ t,
77
+ 'customers.deals.kanban.currencyBreakdown.headingCount',
78
+ '({count} deals)',
79
+ { count: headingCount },
80
+ )
81
+
82
+ return (
83
+ <Popover open={open} onOpenChange={setOpen}>
84
+ <PopoverTrigger asChild>
85
+ <Button
86
+ variant="ghost"
87
+ size="sm"
88
+ type="button"
89
+ aria-label={translateWithFallback(
90
+ t,
91
+ 'customers.deals.kanban.lane.currencyBreakdown.trigger',
92
+ 'Show per-currency breakdown ({count} currencies)',
93
+ { count: rows.length },
94
+ )}
95
+ className={
96
+ triggerClassName ??
97
+ 'inline-flex shrink-0 items-center rounded-full bg-muted px-2 py-1 text-xs font-bold leading-normal text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
98
+ }
99
+ >
100
+ {triggerLabel}
101
+ </Button>
102
+ </PopoverTrigger>
103
+ <PopoverContent
104
+ align="end"
105
+ className="w-80 rounded-2xl border-border bg-transparent p-0 shadow-xl"
106
+ onCloseAutoFocus={(event) => event.preventDefault()}
107
+ >
108
+ {/*
109
+ Solid `bg-card` (no opacity modifier) on the outer wrapper: floating overlays must not
110
+ let underlying card content bleed through. Round-2 UX review item 33 — same class of
111
+ bug as item 6 in the shared FilterPopoverShell, but in popovers that hand-roll their
112
+ chrome instead of using the shell.
113
+ */}
114
+ <div className="flex flex-col overflow-hidden rounded-2xl bg-card">
115
+ <div className="flex items-center justify-between border-b border-border bg-card px-5 py-4">
116
+ <span className="text-base font-bold leading-normal text-foreground">{titleLabel}</span>
117
+ <IconButton
118
+ variant="ghost"
119
+ size="xs"
120
+ onClick={() => setOpen(false)}
121
+ aria-label={closeLabel}
122
+ className="flex size-7 items-center justify-center rounded-md border border-border bg-card 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"
123
+ >
124
+ <X className="size-3.5" aria-hidden="true" />
125
+ </IconButton>
126
+ </div>
127
+
128
+ <div className="flex flex-col gap-1.5 bg-card px-6 pt-3.5 pb-2">
129
+ <span className="text-overline font-semibold uppercase tracking-wider text-muted-foreground">
130
+ {headingLabel} {headingCountLabel}
131
+ </span>
132
+ <div className="flex items-baseline gap-2">
133
+ <span className="text-2xl font-bold leading-normal text-foreground">
134
+ {formatAmount(totalInBaseCurrency)}
135
+ </span>
136
+ {baseCurrencyCode ? (
137
+ <span className="text-sm font-normal leading-normal text-muted-foreground">
138
+ {baseCurrencyCode}
139
+ </span>
140
+ ) : null}
141
+ </div>
142
+ </div>
143
+
144
+ <CurrencyBreakdownTable
145
+ rows={rows}
146
+ baseCurrencyCode={baseCurrencyCode}
147
+ missingRateCurrencies={missingRateCurrencies}
148
+ />
149
+
150
+ <div className="bg-card px-6 pt-3 pb-3.5">
151
+ <p className="text-xs font-normal leading-normal text-muted-foreground">
152
+ {translateWithFallback(
153
+ t,
154
+ 'customers.deals.kanban.currencyBreakdown.footerCredit',
155
+ 'NBP mid rate · {date}',
156
+ { date: formatToday() },
157
+ )}
158
+ </p>
159
+ {!convertedAll && missingRateCurrencies.length > 0 ? (
160
+ <p className="mt-2 text-xs leading-normal text-muted-foreground">
161
+ {translateWithFallback(
162
+ t,
163
+ 'customers.deals.kanban.lane.currencyBreakdown.missingRatesHint',
164
+ 'Missing FX rates for {currencies} — excluded from total. Configure exchange rates to see the full converted value.',
165
+ { currencies: missingRateCurrencies.join(', ') },
166
+ )}
167
+ </p>
168
+ ) : null}
169
+ </div>
170
+ </div>
171
+ </PopoverContent>
172
+ </Popover>
173
+ )
174
+ }
175
+
176
+ function formatAmount(amount: number): string {
177
+ return new Intl.NumberFormat(undefined, {
178
+ style: 'decimal',
179
+ maximumFractionDigits: 0,
180
+ useGrouping: true,
181
+ }).format(Math.round(amount))
182
+ }
183
+
184
+ function formatToday(): string {
185
+ return new Intl.DateTimeFormat(undefined, {
186
+ year: 'numeric',
187
+ month: 'short',
188
+ day: '2-digit',
189
+ }).format(new Date())
190
+ }
191
+
192
+ export default LaneCurrencyBreakdown
@@ -0,0 +1,107 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
6
+
7
+ type LaneResizeHandleProps = {
8
+ /** Called with the cumulative delta (px) as the user drags. */
9
+ onResize: (deltaPx: number) => void
10
+ /** Called once when the drag ends. */
11
+ onResizeEnd?: () => void
12
+ /** Called on double-click — convention is "reset this lane to default width". */
13
+ onReset?: () => void
14
+ }
15
+
16
+ /**
17
+ * Thin vertical drag handle that sits on the right edge of a kanban lane.
18
+ *
19
+ * UX (matches Monday / Asana / ClickUp):
20
+ * - Hover: 4 px column highlights, cursor flips to `col-resize`
21
+ * - Drag: lane width updates live via `onResize`
22
+ * - Double-click: emits `onReset` (the lane resets to its default width)
23
+ *
24
+ * The pointer listeners attach to `window` during the drag so the handle keeps tracking
25
+ * even when the cursor moves off the handle's narrow hit area.
26
+ */
27
+ export function LaneResizeHandle({ onResize, onResizeEnd, onReset }: LaneResizeHandleProps): React.ReactElement {
28
+ const t = useT()
29
+ const dragStateRef = React.useRef<{ lastX: number } | null>(null)
30
+ const [isActive, setIsActive] = React.useState(false)
31
+
32
+ const stopDrag = React.useCallback(
33
+ (handlers: { move: (e: PointerEvent) => void; up: () => void }) => {
34
+ window.removeEventListener('pointermove', handlers.move)
35
+ window.removeEventListener('pointerup', handlers.up)
36
+ window.removeEventListener('pointercancel', handlers.up)
37
+ document.body.style.cursor = ''
38
+ document.body.style.userSelect = ''
39
+ setIsActive(false)
40
+ onResizeEnd?.()
41
+ dragStateRef.current = null
42
+ },
43
+ [onResizeEnd],
44
+ )
45
+
46
+ const handlePointerDown = React.useCallback(
47
+ (event: React.PointerEvent) => {
48
+ // Only respond to primary (left) mouse button / touch / pen
49
+ if (event.button !== 0 && event.pointerType === 'mouse') return
50
+ event.preventDefault()
51
+ event.stopPropagation()
52
+ setIsActive(true)
53
+ document.body.style.cursor = 'col-resize'
54
+ document.body.style.userSelect = 'none'
55
+ dragStateRef.current = { lastX: event.clientX }
56
+
57
+ const handlers = {
58
+ move: (e: PointerEvent) => {
59
+ if (!dragStateRef.current) return
60
+ const delta = e.clientX - dragStateRef.current.lastX
61
+ if (delta !== 0) {
62
+ dragStateRef.current.lastX = e.clientX
63
+ onResize(delta)
64
+ }
65
+ },
66
+ up: () => stopDrag(handlers),
67
+ }
68
+ window.addEventListener('pointermove', handlers.move)
69
+ window.addEventListener('pointerup', handlers.up)
70
+ window.addEventListener('pointercancel', handlers.up)
71
+ },
72
+ [onResize, stopDrag],
73
+ )
74
+
75
+ const handleDoubleClick = React.useCallback(
76
+ (event: React.MouseEvent) => {
77
+ event.preventDefault()
78
+ event.stopPropagation()
79
+ onReset?.()
80
+ },
81
+ [onReset],
82
+ )
83
+
84
+ return (
85
+ <div
86
+ role="separator"
87
+ aria-orientation="vertical"
88
+ aria-label={translateWithFallback(
89
+ t,
90
+ 'customers.deals.kanban.lane.resizeHandle',
91
+ 'Drag to resize column. Double-click to reset width.',
92
+ )}
93
+ onPointerDown={handlePointerDown}
94
+ onDoubleClick={handleDoubleClick}
95
+ title={translateWithFallback(
96
+ t,
97
+ 'customers.deals.kanban.lane.resizeHandle',
98
+ 'Drag to resize · double-click to reset',
99
+ )}
100
+ className={`absolute -right-0.5 top-0 z-20 h-full w-1.5 cursor-col-resize touch-none select-none ${
101
+ isActive ? 'bg-accent-indigo/40' : 'bg-transparent hover:bg-accent-indigo/20'
102
+ }`}
103
+ />
104
+ )
105
+ }
106
+
107
+ export default LaneResizeHandle