@open-mercato/core 0.6.3-develop.3651.1.d3a548a5f1 → 0.6.3-develop.3660.1.53b73d5aa4

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,562 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { useDraggable } from '@dnd-kit/core'
5
+ import { AlertTriangle, Building2, Calendar, Clock, Mail, Phone, StickyNote } from 'lucide-react'
6
+ import { Avatar } from '@open-mercato/ui/primitives/avatar'
7
+ import { Button } from '@open-mercato/ui/primitives/button'
8
+ import { Checkbox } from '@open-mercato/ui/primitives/checkbox'
9
+ import type { RowActionItem } from '@open-mercato/ui/backend/RowActions'
10
+ import { DealCardMenu } from './DealCardMenu'
11
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
12
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
13
+
14
+ export type DealCardPipelineState = {
15
+ openActivitiesCount: number
16
+ daysInCurrentStage: number
17
+ isStuck: boolean
18
+ isOverdue: boolean
19
+ }
20
+
21
+ export type DealCardAssociation = {
22
+ id: string
23
+ label: string
24
+ }
25
+
26
+ export type DealCardOwner = {
27
+ userId: string
28
+ label: string
29
+ }
30
+
31
+ export type DealCardData = {
32
+ id: string
33
+ title: string
34
+ status: string | null
35
+ valueAmount: number | null
36
+ valueCurrency: string | null
37
+ probability: number | null
38
+ expectedCloseAt: string | null
39
+ createdAt: string | null
40
+ /** ISO timestamp of last update — drives the "Updated (newest/oldest)" client-side sort fallback. */
41
+ updatedAt: string | null
42
+ owner: DealCardOwner | null
43
+ primaryCompany: DealCardAssociation | null
44
+ pipelineState: DealCardPipelineState
45
+ }
46
+
47
+ type DealCardProps = {
48
+ deal: DealCardData
49
+ selected: boolean
50
+ /**
51
+ * `true` whenever at least one deal anywhere on the board is selected. Forces the
52
+ * checkbox to render even when this card is not yet selected and not currently hovered,
53
+ * so the operator can multi-select without re-aiming at the small 16px target after
54
+ * every click. Without this, the second-click had to land precisely on the still-hidden
55
+ * checkbox area before the `:hover` made it visible, which is what the spec called
56
+ * "persistent visibility once any card is selected".
57
+ */
58
+ bulkSelectionActive?: boolean
59
+ /** Stable function called once per render to build the row-action items for this deal */
60
+ buildMenuItems: (deal: DealCardData) => RowActionItem[]
61
+ extraCompaniesCount?: number
62
+ extraOwners?: DealCardOwner[]
63
+ isActiveDrag?: boolean
64
+ onToggleSelect: (dealId: string) => void
65
+ onComposeActivity: (dealId: string, type: 'call' | 'email' | 'note') => void
66
+ onOpenDetail: (dealId: string) => void
67
+ }
68
+
69
+ const ACTIVITY_BADGE_CAP = 9
70
+ const ACTIVITY_BADGE_WARNING_THRESHOLD = 9
71
+ const AVATAR_STACK_MAX = 3
72
+
73
+ // Hash-derived avatar accent classes that map to semantic DS tokens (no hardcoded oklch/hex).
74
+ // Each entry uses the saturated `*-icon` background (Figma calls for vivid, not pale, avatars)
75
+ // paired with white-on-tone text since `*-icon` tokens are saturated foreground colors. The
76
+ // choice is deterministic-but-arbitrary — we only need stable visual differentiation between
77
+ // different owners, not semantic mapping to status meanings.
78
+ const AVATAR_ACCENT_CLASSES: string[] = [
79
+ 'bg-status-info-icon text-white',
80
+ 'bg-brand-violet text-brand-violet-foreground',
81
+ 'bg-status-warning-icon text-white',
82
+ 'bg-status-neutral-icon text-white',
83
+ 'bg-status-success-icon text-white',
84
+ 'bg-status-pink-icon text-white',
85
+ ]
86
+
87
+ function hashAccent(seed: string): string {
88
+ // Deterministic hash (Java string-hash variant). The bit pattern is not cryptographically
89
+ // meaningful — it only needs to spread inputs across the palette indices.
90
+ let h = 0
91
+ for (let i = 0; i < seed.length; i += 1) {
92
+ h = ((h << 5) - h + seed.charCodeAt(i)) | 0
93
+ }
94
+ const idx = Math.abs(h) % AVATAR_ACCENT_CLASSES.length
95
+ return AVATAR_ACCENT_CLASSES[idx]
96
+ }
97
+
98
+ function splitCurrencyAmount(amount: number, currency: string | null): { display: string; code: string | null } {
99
+ const code = currency && currency.length === 3 ? currency.toUpperCase() : null
100
+ try {
101
+ const formatter = new Intl.NumberFormat(undefined, {
102
+ style: 'decimal',
103
+ maximumFractionDigits: 0,
104
+ useGrouping: true,
105
+ })
106
+ return { display: formatter.format(amount), code }
107
+ } catch {
108
+ return { display: String(Math.round(amount)), code }
109
+ }
110
+ }
111
+
112
+ function formatProbability(value: number | null): string | null {
113
+ if (typeof value !== 'number' || Number.isNaN(value)) return null
114
+ return `${Math.min(Math.max(Math.round(value), 0), 100)}%`
115
+ }
116
+
117
+ const shortDateFormatter = new Intl.DateTimeFormat(undefined, {
118
+ month: 'short',
119
+ day: '2-digit',
120
+ })
121
+
122
+ function formatShortDate(value: string | null): string | null {
123
+ if (!value) return null
124
+ const date = new Date(value)
125
+ if (Number.isNaN(date.getTime())) return null
126
+ return shortDateFormatter.format(date)
127
+ }
128
+
129
+ function shortDealRef(id: string): string {
130
+ return `DEAL-${id.slice(0, 6).toUpperCase()}`
131
+ }
132
+
133
+ function DealCardImpl({
134
+ deal,
135
+ selected,
136
+ bulkSelectionActive = false,
137
+ buildMenuItems,
138
+ extraCompaniesCount = 0,
139
+ extraOwners,
140
+ isActiveDrag = false,
141
+ onToggleSelect,
142
+ onComposeActivity,
143
+ onOpenDetail,
144
+ }: DealCardProps): React.ReactElement {
145
+ const menuItems = React.useMemo(() => buildMenuItems(deal), [buildMenuItems, deal])
146
+ const t = useT()
147
+ // useDraggable is significantly cheaper than useSortable: dnd-kit doesn't recompute sibling
148
+ // transforms during a drag. We don't reorder within a lane (sortBy controls order), so we just
149
+ // need a draggable handle that drops into a Lane droppable.
150
+ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
151
+ id: deal.id,
152
+ data: { type: 'deal' },
153
+ })
154
+ const dimmed = isActiveDrag || isDragging
155
+
156
+ // Note: no transform CSS — the DragOverlay shows the moving copy, while the source card stays
157
+ // visually in place (just dimmed). This removes per-frame transform recomputation.
158
+ const style: React.CSSProperties = {}
159
+
160
+ const activityCount = deal.pipelineState.openActivitiesCount
161
+ const activityBadgeLabel =
162
+ activityCount > 0
163
+ ? activityCount > ACTIVITY_BADGE_CAP
164
+ ? `${ACTIVITY_BADGE_CAP}+`
165
+ : String(activityCount)
166
+ : null
167
+ const activityWarning = activityCount >= ACTIVITY_BADGE_WARNING_THRESHOLD
168
+
169
+ const probabilityLabel = formatProbability(deal.probability)
170
+ const dateLabel = formatShortDate(deal.expectedCloseAt ?? deal.createdAt)
171
+ const stageDaysLabel = deal.pipelineState.isOverdue
172
+ ? translateWithFallback(t, 'customers.deals.kanban.card.overdueLabel', 'Overdue')
173
+ : translateWithFallback(t, 'customers.deals.kanban.card.daysInStage', 'in {days}d', {
174
+ days: deal.pipelineState.daysInCurrentStage,
175
+ })
176
+
177
+ const showOverdue = deal.pipelineState.isOverdue
178
+ const showStuck = !showOverdue && deal.pipelineState.isStuck
179
+
180
+ const handleSelectChange = (next: boolean | 'indeterminate') => {
181
+ if (typeof next === 'boolean' && next !== selected) {
182
+ onToggleSelect(deal.id)
183
+ }
184
+ }
185
+
186
+ const handleCardClick = (event: React.MouseEvent<HTMLDivElement>) => {
187
+ const target = event.target as HTMLElement
188
+ if (target.closest('[data-card-action="true"]')) return
189
+ // Once at least one card is selected the board is in "bulk-select mode" — clicking
190
+ // anywhere on a card toggles that card's selection rather than navigating to the
191
+ // detail page. This matches Gmail/Asana selection semantics and rescues the
192
+ // user-reported failure mode where a slight mis-aim at the 16px checkbox button
193
+ // navigated to the deal instead of adding it to the selection.
194
+ if (bulkSelectionActive) {
195
+ onToggleSelect(deal.id)
196
+ return
197
+ }
198
+ onOpenDetail(deal.id)
199
+ }
200
+
201
+ const handleActionClick = (type: 'call' | 'email' | 'note') => (event: React.MouseEvent) => {
202
+ event.stopPropagation()
203
+ onComposeActivity(deal.id, type)
204
+ }
205
+
206
+ // dnd-kit's `useDraggable` listeners set pointer capture on the card when a pointerdown lands
207
+ // anywhere inside it. With capture active, the subsequent `click` event's `target` is the card
208
+ // itself rather than the inner button/checkbox the user actually clicked — which made
209
+ // `handleCardClick`'s `closest('[data-card-action="true"]')` check return null, so every click
210
+ // inside Select / Call / Email / Note / kebab navigated to the deal detail instead of running
211
+ // the intended handler.
212
+ //
213
+ // We stop propagation of `onPointerDown` at the `data-card-action="true"` boundary so dnd-kit's
214
+ // listener never sees the pointerdown and never sets capture. React's `onClick` handlers on the
215
+ // children then run normally with the right `event.target`.
216
+ const stopPointerDown = React.useCallback((event: React.PointerEvent) => {
217
+ event.stopPropagation()
218
+ }, [])
219
+
220
+ const ariaLabel = translateWithFallback(t, 'customers.deals.kanban.card.aria', 'Deal: {title}', {
221
+ title: deal.title,
222
+ })
223
+ const ariaRoleDescription = translateWithFallback(
224
+ t,
225
+ 'customers.deals.kanban.card.aria.roledescription',
226
+ 'deal card',
227
+ )
228
+
229
+ const valuePieces =
230
+ deal.valueAmount !== null && Number.isFinite(deal.valueAmount)
231
+ ? splitCurrencyAmount(deal.valueAmount, deal.valueCurrency)
232
+ : null
233
+
234
+ // Probability pill tone tracks the probability VALUE, not stage state. Figma uses three
235
+ // tiers (nodes 982:369/417/674/745/797/928/1002):
236
+ // <40% → de-emphasized (gray bg + faded text) — Figma `bg-[#f4f5f7] text-[#808794]`
237
+ // 40-69% → warning tone (orange bg + warning text) — Figma `bg-[#fff2e0] text-[#fe9a00]`
238
+ // ≥70% → emphasized (gray bg + dark text) — Figma `bg-[#f4f5f7] text-[#474c5a]`
239
+ //
240
+ // Importantly the high-probability tier is NOT green: Figma keeps the neutral surface and
241
+ // signals confidence with darker foreground text instead. Stuck/overdue state is already
242
+ // surfaced by the separate badge above, so coloring this pill by stage state (the previous
243
+ // behavior) double-stacked the signal and made e.g. "80% on a red background" read as
244
+ // "80% is bad" — the inverse of intent.
245
+ const probabilityValue = typeof deal.probability === 'number' ? deal.probability : null
246
+ const probabilityPillClass =
247
+ probabilityValue !== null && probabilityValue >= 70
248
+ ? 'bg-muted text-foreground'
249
+ : probabilityValue !== null && probabilityValue >= 40
250
+ ? 'bg-status-warning-bg text-status-warning-text'
251
+ : 'bg-muted text-muted-foreground'
252
+
253
+ const activityBadgeClass = activityWarning
254
+ ? 'bg-status-warning-bg text-status-warning-text'
255
+ : 'bg-muted text-foreground'
256
+
257
+ const ownersStack: DealCardOwner[] = React.useMemo(() => {
258
+ if (!deal.owner) return extraOwners ?? []
259
+ return [deal.owner, ...(extraOwners ?? [])]
260
+ }, [deal.owner, extraOwners])
261
+
262
+ const visibleOwners = ownersStack.slice(0, AVATAR_STACK_MAX)
263
+ const ownerOverflow = ownersStack.length - visibleOwners.length
264
+
265
+ return (
266
+ <div
267
+ ref={setNodeRef}
268
+ style={style}
269
+ {...attributes}
270
+ {...listeners}
271
+ aria-label={ariaLabel}
272
+ aria-roledescription={ariaRoleDescription}
273
+ onClick={handleCardClick}
274
+ className={`group relative flex w-full flex-col gap-3 rounded-lg border border-border bg-card px-4 py-3.5 shadow-xs transition-shadow ${
275
+ dimmed
276
+ ? 'cursor-grabbing opacity-30'
277
+ : bulkSelectionActive
278
+ ? 'cursor-pointer hover:shadow-sm'
279
+ : 'cursor-grab hover:shadow-sm active:cursor-grabbing'
280
+ } ${selected ? 'ring-2 ring-accent-indigo' : ''}`}
281
+ >
282
+ <div className="flex items-start justify-between gap-2.5">
283
+ <div
284
+ data-card-action="true"
285
+ className={`mr-0.5 mt-0.5 flex shrink-0 transition-opacity ${
286
+ selected || bulkSelectionActive
287
+ ? 'opacity-100'
288
+ // `opacity-0` alone leaves the element in the DOM with hit-testable pointer
289
+ // areas — its `onPointerDown={stopPointerDown}` would then swallow drag
290
+ // starts whenever the operator grabs the card near the (invisible) checkbox,
291
+ // and dnd-kit never sees the gesture. Pairing with `pointer-events-none`
292
+ // lets the drag pass through while preserving tab/focus reachability (focus
293
+ // events ignore `pointer-events`), and the matching `group-hover` /
294
+ // `focus-within` variants restore hit testing the moment the affordance
295
+ // becomes visible. `[@media(hover:none)]` keeps it always-on for touch.
296
+ : 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto [@media(hover:none)]:opacity-100 [@media(hover:none)]:pointer-events-auto'
297
+ }`}
298
+ onClick={(event) => event.stopPropagation()}
299
+ onPointerDown={stopPointerDown}
300
+ >
301
+ <Checkbox
302
+ checked={selected}
303
+ onCheckedChange={handleSelectChange}
304
+ aria-label={translateWithFallback(t, 'customers.deals.kanban.card.aria.select', 'Select deal')}
305
+ />
306
+ </div>
307
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
308
+ <h3 className="line-clamp-2 text-base font-semibold leading-normal text-foreground">
309
+ {deal.title}
310
+ </h3>
311
+ <span className="text-xs leading-normal text-muted-foreground">{shortDealRef(deal.id)}</span>
312
+ </div>
313
+ <div
314
+ className="flex shrink-0 items-center gap-2"
315
+ data-card-action="true"
316
+ onClick={(event) => event.stopPropagation()}
317
+ onPointerDown={stopPointerDown}
318
+ >
319
+ {activityBadgeLabel ? (
320
+ <span
321
+ className={`inline-flex items-center justify-center rounded-full px-2 py-px text-xs font-bold leading-normal ${activityBadgeClass}`}
322
+ aria-label={translateWithFallback(
323
+ t,
324
+ 'customers.deals.kanban.card.aria.openActivities',
325
+ '{count} open activities',
326
+ { count: activityCount },
327
+ )}
328
+ >
329
+ {activityBadgeLabel}
330
+ </span>
331
+ ) : null}
332
+ <DealCardMenu
333
+ items={menuItems}
334
+ ariaLabel={translateWithFallback(
335
+ t,
336
+ 'customers.deals.kanban.card.aria.menu',
337
+ 'Deal actions',
338
+ )}
339
+ />
340
+ </div>
341
+ </div>
342
+
343
+ {showOverdue || showStuck ? (
344
+ <div>
345
+ {showOverdue ? (
346
+ <span className="inline-flex items-center gap-1.5 rounded-md bg-status-error-bg px-2.5 py-1 text-xs font-semibold leading-normal text-status-error-text">
347
+ <AlertTriangle className="size-3.5" aria-hidden="true" />
348
+ {translateWithFallback(t, 'customers.deals.kanban.card.statusOverdue', 'OVERDUE')}
349
+ </span>
350
+ ) : (
351
+ <span className="inline-flex items-center gap-1.5 rounded-md bg-status-warning-bg px-2.5 py-1 text-xs font-semibold leading-normal text-status-warning-text">
352
+ <Clock className="size-3.5" aria-hidden="true" />
353
+ {translateWithFallback(t, 'customers.deals.kanban.card.statusStuck', 'STUCK')}
354
+ </span>
355
+ )}
356
+ </div>
357
+ ) : null}
358
+
359
+ {/*
360
+ Quick-log activity actions. `customers.activities` are scoped to a parent entity
361
+ (person/company), so without a primary company on this deal the activity composer
362
+ has nowhere to anchor a new record. The page-level handler previously flashed a
363
+ toast and bailed silently, which was confusing — we surface the same constraint
364
+ here as a disabled-with-tooltip state so the operator can fix the underlying data.
365
+ */}
366
+ <div
367
+ data-card-action="true"
368
+ // See note on the checkbox wrapper above — pairing `opacity-0` with `pointer-events-none`
369
+ // is what restores drag-and-drop. Without it the (invisible) Call/Email/Note row sits in
370
+ // the middle of the card and intercepts every pointer-down, so dnd-kit never gets the
371
+ // gesture and the card snaps back to the source lane.
372
+ className="flex items-center gap-1 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto [@media(hover:none)]:opacity-100 [@media(hover:none)]:pointer-events-auto"
373
+ onClick={(event) => event.stopPropagation()}
374
+ onPointerDown={stopPointerDown}
375
+ >
376
+ {(() => {
377
+ const disabled = !deal.primaryCompany
378
+ const disabledTitle = disabled
379
+ ? translateWithFallback(
380
+ t,
381
+ 'customers.deals.kanban.card.action.disabledNoCompany',
382
+ 'Link a company to this deal before logging activities.',
383
+ )
384
+ : undefined
385
+ const baseClass =
386
+ 'inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md px-2 py-1 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
387
+ return (
388
+ <>
389
+ <Button
390
+ variant="ghost"
391
+ size="sm"
392
+ type="button"
393
+ onClick={handleActionClick('call')}
394
+ disabled={disabled}
395
+ title={disabledTitle}
396
+ aria-disabled={disabled || undefined}
397
+ className={baseClass}
398
+ >
399
+ <Phone className="size-3.5 shrink-0" aria-hidden="true" />
400
+ <span>{translateWithFallback(t, 'customers.deals.kanban.card.action.call', 'Call')}</span>
401
+ </Button>
402
+ <Button
403
+ variant="ghost"
404
+ size="sm"
405
+ type="button"
406
+ onClick={handleActionClick('email')}
407
+ disabled={disabled}
408
+ title={disabledTitle}
409
+ aria-disabled={disabled || undefined}
410
+ className={baseClass}
411
+ >
412
+ <Mail className="size-3.5 shrink-0" aria-hidden="true" />
413
+ <span>{translateWithFallback(t, 'customers.deals.kanban.card.action.email', 'Email')}</span>
414
+ </Button>
415
+ <Button
416
+ variant="ghost"
417
+ size="sm"
418
+ type="button"
419
+ onClick={handleActionClick('note')}
420
+ disabled={disabled}
421
+ title={disabledTitle}
422
+ aria-disabled={disabled || undefined}
423
+ className={baseClass}
424
+ >
425
+ <StickyNote className="size-3.5 shrink-0" aria-hidden="true" />
426
+ <span>{translateWithFallback(t, 'customers.deals.kanban.card.action.note', 'Note')}</span>
427
+ </Button>
428
+ </>
429
+ )
430
+ })()}
431
+ </div>
432
+
433
+ <div className="flex items-center justify-between gap-2.5">
434
+ {valuePieces ? (
435
+ <div className="flex items-baseline gap-1.5">
436
+ <span className="text-lg font-bold leading-normal text-foreground">
437
+ {valuePieces.display}
438
+ </span>
439
+ {valuePieces.code ? (
440
+ <span className="text-sm font-semibold leading-normal text-muted-foreground">
441
+ {valuePieces.code}
442
+ </span>
443
+ ) : null}
444
+ </div>
445
+ ) : (
446
+ <span className="text-sm text-muted-foreground">
447
+ {translateWithFallback(t, 'customers.deals.kanban.card.noValue', 'No value')}
448
+ </span>
449
+ )}
450
+ {probabilityLabel ? (
451
+ <span
452
+ className={`inline-flex items-center rounded-md px-2.5 py-1 text-sm font-semibold leading-normal ${probabilityPillClass}`}
453
+ aria-label={translateWithFallback(
454
+ t,
455
+ 'customers.deals.kanban.card.aria.probability',
456
+ 'Probability: {value}',
457
+ { value: probabilityLabel },
458
+ )}
459
+ >
460
+ {probabilityLabel}
461
+ </span>
462
+ ) : null}
463
+ </div>
464
+
465
+ {deal.primaryCompany || extraCompaniesCount > 0 ? (
466
+ <div className="flex items-center gap-1.5">
467
+ {deal.primaryCompany ? (
468
+ <span className="inline-flex 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">
469
+ <Building2 className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
470
+ <span className="truncate">{deal.primaryCompany.label}</span>
471
+ </span>
472
+ ) : null}
473
+ {extraCompaniesCount > 0 ? (
474
+ <span
475
+ className="inline-flex shrink-0 items-center rounded-md bg-muted px-2.5 py-1 text-sm font-semibold leading-normal text-muted-foreground"
476
+ aria-label={translateWithFallback(
477
+ t,
478
+ 'customers.deals.kanban.card.aria.moreCompanies',
479
+ '{count} more companies',
480
+ { count: extraCompaniesCount },
481
+ )}
482
+ >
483
+ +{extraCompaniesCount}
484
+ </span>
485
+ ) : null}
486
+ </div>
487
+ ) : null}
488
+
489
+ <div className="h-px w-full bg-border" aria-hidden="true" />
490
+
491
+ <div className="flex items-center justify-between gap-2.5">
492
+ <div className="flex items-center gap-2 text-sm leading-normal">
493
+ {dateLabel ? (
494
+ <span className="inline-flex items-center gap-2">
495
+ <Calendar
496
+ className={`size-3.5 ${showOverdue ? 'text-status-error-icon' : 'text-muted-foreground'}`}
497
+ aria-hidden="true"
498
+ />
499
+ <span
500
+ className={
501
+ showOverdue
502
+ ? 'font-semibold text-status-error-text'
503
+ : 'font-semibold text-foreground'
504
+ }
505
+ >
506
+ {dateLabel}
507
+ </span>
508
+ </span>
509
+ ) : null}
510
+ <span
511
+ className={
512
+ showOverdue
513
+ ? 'font-semibold text-status-error-text'
514
+ : 'font-normal text-muted-foreground'
515
+ }
516
+ >
517
+ {stageDaysLabel}
518
+ </span>
519
+ </div>
520
+ {ownersStack.length > 0 ? (
521
+ <div className="flex items-center">
522
+ {visibleOwners.map((owner, idx) => {
523
+ const accentClass = hashAccent(owner.userId)
524
+ return (
525
+ <Avatar
526
+ key={`${owner.userId}-${idx}`}
527
+ label={owner.label || owner.userId.slice(0, 2).toUpperCase()}
528
+ size="sm"
529
+ className={`size-7 text-xs font-bold ring-2 ring-card ${accentClass} ${idx > 0 ? '-ml-2.5' : ''}`}
530
+ />
531
+ )
532
+ })}
533
+ {ownerOverflow > 0 ? (
534
+ <Avatar
535
+ label={`+${ownerOverflow}`}
536
+ size="sm"
537
+ variant="monochrome"
538
+ className="size-7 -ml-2.5 text-xs font-bold ring-2 ring-card"
539
+ />
540
+ ) : null}
541
+ </div>
542
+ ) : null}
543
+ </div>
544
+ </div>
545
+ )
546
+ }
547
+
548
+ // Memoize so cards in lanes far from the drag don't re-render on drag-state changes.
549
+ // Custom equality keeps the comparison cheap (we only re-render when something visible changes).
550
+ export const DealCard = React.memo(DealCardImpl, (prev, next) => {
551
+ if (prev.deal !== next.deal) return false
552
+ if (prev.selected !== next.selected) return false
553
+ if (prev.bulkSelectionActive !== next.bulkSelectionActive) return false
554
+ if (prev.isActiveDrag !== next.isActiveDrag) return false
555
+ if (prev.buildMenuItems !== next.buildMenuItems) return false
556
+ if (prev.extraCompaniesCount !== next.extraCompaniesCount) return false
557
+ if (prev.extraOwners !== next.extraOwners) return false
558
+ // Callback identity changes do NOT trigger re-renders — they're stable handlers from the page
559
+ return true
560
+ })
561
+
562
+ export default DealCard