@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,214 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { createPortal } from 'react-dom'
5
+ import { MoreVertical } from 'lucide-react'
6
+ import { Button } from '@open-mercato/ui/primitives/button'
7
+ import { IconButton } from '@open-mercato/ui/primitives/icon-button'
8
+ import type { RowActionItem } from '@open-mercato/ui/backend/RowActions'
9
+
10
+ type DealCardMenuProps = {
11
+ items: RowActionItem[]
12
+ ariaLabel: string
13
+ }
14
+
15
+ const MENU_GAP = 6
16
+
17
+ // Right-aligned shortcut hint inside a menu row. Mirrors the Figma deal-card menu
18
+ // (node 1045:12254), which shows ⏎ for Open, E for Edit, ⌘D for Duplicate.
19
+ // `aria-hidden="true"` keeps the kbd out of the menu item's accessible name —
20
+ // without this the menu item reads as "Open deal ↵" instead of "Open deal" and
21
+ // `getByRole('menuitem', { name: 'Open deal', exact: true })` fails in tests.
22
+ function MenuShortcut({ children }: { children: React.ReactNode }): React.ReactElement {
23
+ return (
24
+ <kbd
25
+ aria-hidden="true"
26
+ className="ml-auto inline-flex items-center rounded border border-border bg-muted px-1.5 py-px font-mono text-[10px] font-medium leading-none text-muted-foreground"
27
+ >
28
+ {children}
29
+ </kbd>
30
+ )
31
+ }
32
+
33
+ // Map of menu-item id → keyboard shortcut hint. Stay in sync with `buildMenuItems` in
34
+ // `pipeline/page.tsx` and with `matchesShortcut` below.
35
+ const ITEM_SHORTCUTS: Record<string, React.ReactNode> = {
36
+ open: '↵',
37
+ edit: 'E',
38
+ duplicate: '⌘D',
39
+ }
40
+
41
+ function matchesShortcut(itemId: string, event: KeyboardEvent): boolean {
42
+ if (itemId === 'open' && event.key === 'Enter') return true
43
+ if (itemId === 'edit' && (event.key === 'e' || event.key === 'E') && !event.metaKey && !event.ctrlKey) return true
44
+ if (itemId === 'duplicate' && (event.key === 'd' || event.key === 'D') && (event.metaKey || event.ctrlKey)) return true
45
+ return false
46
+ }
47
+
48
+ export function DealCardMenu({ items, ariaLabel }: DealCardMenuProps): React.ReactElement | null {
49
+ const [open, setOpen] = React.useState(false)
50
+ const [anchor, setAnchor] = React.useState<DOMRect | null>(null)
51
+ const [direction, setDirection] = React.useState<'down' | 'up'>('down')
52
+ const btnRef = React.useRef<HTMLButtonElement>(null)
53
+ const menuRef = React.useRef<HTMLDivElement>(null)
54
+
55
+ const updatePosition = React.useCallback(() => {
56
+ if (!btnRef.current) return
57
+ const rect = btnRef.current.getBoundingClientRect()
58
+ setAnchor(rect)
59
+ const spaceBelow = window.innerHeight - rect.bottom
60
+ const spaceAbove = rect.top
61
+ setDirection(spaceBelow < 220 && spaceAbove > spaceBelow ? 'up' : 'down')
62
+ }, [])
63
+
64
+ React.useEffect(() => {
65
+ if (!open) return
66
+ updatePosition()
67
+ // Autofocus the first menu item once the portal has painted. We schedule this on the
68
+ // next frame so the `createPortal` render has actually mounted the buttons in the DOM.
69
+ requestAnimationFrame(() => {
70
+ const first = menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]')
71
+ first?.focus()
72
+ })
73
+ function onDocClick(e: MouseEvent) {
74
+ const target = e.target as Node
75
+ if (
76
+ menuRef.current &&
77
+ !menuRef.current.contains(target) &&
78
+ btnRef.current &&
79
+ !btnRef.current.contains(target)
80
+ ) {
81
+ setOpen(false)
82
+ }
83
+ }
84
+ function onKey(e: KeyboardEvent) {
85
+ if (e.key === 'Escape') {
86
+ setOpen(false)
87
+ btnRef.current?.focus()
88
+ return
89
+ }
90
+ // Hotkey wiring: when the menu is open and focus is inside it, ⏎/E/⌘D trigger
91
+ // the matching action. Skip when an editable surface owns focus.
92
+ const active = document.activeElement as HTMLElement | null
93
+ if (active) {
94
+ const tag = active.tagName
95
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return
96
+ }
97
+ if (!menuRef.current?.contains(active)) return
98
+ for (const item of items) {
99
+ if (item.id && matchesShortcut(item.id, e)) {
100
+ e.preventDefault()
101
+ setOpen(false)
102
+ item.onSelect?.()
103
+ return
104
+ }
105
+ }
106
+ }
107
+ function onScrollOrResize() {
108
+ updatePosition()
109
+ }
110
+ document.addEventListener('mousedown', onDocClick)
111
+ document.addEventListener('keydown', onKey)
112
+ window.addEventListener('scroll', onScrollOrResize, true)
113
+ window.addEventListener('resize', onScrollOrResize)
114
+ return () => {
115
+ document.removeEventListener('mousedown', onDocClick)
116
+ document.removeEventListener('keydown', onKey)
117
+ window.removeEventListener('scroll', onScrollOrResize, true)
118
+ window.removeEventListener('resize', onScrollOrResize)
119
+ }
120
+ }, [open, updatePosition])
121
+
122
+ // Arrow-key navigation between menu items. Scoped to the menu so it doesn't fight with
123
+ // global shortcuts when something else is focused.
124
+ const handleMenuKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
125
+ const items = Array.from(
126
+ menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [],
127
+ )
128
+ if (items.length === 0) return
129
+ const currentIdx = items.findIndex((el) => el === document.activeElement)
130
+ if (event.key === 'ArrowDown') {
131
+ event.preventDefault()
132
+ const next = items[(currentIdx + 1 + items.length) % items.length]
133
+ next?.focus()
134
+ } else if (event.key === 'ArrowUp') {
135
+ event.preventDefault()
136
+ const prev = items[(currentIdx - 1 + items.length) % items.length]
137
+ prev?.focus()
138
+ } else if (event.key === 'Home') {
139
+ event.preventDefault()
140
+ items[0]?.focus()
141
+ } else if (event.key === 'End') {
142
+ event.preventDefault()
143
+ items[items.length - 1]?.focus()
144
+ }
145
+ }, [])
146
+
147
+ if (!items.length) return null
148
+
149
+ return (
150
+ <div className="relative inline-flex" data-card-action="true" onClick={(event) => event.stopPropagation()}>
151
+ <IconButton
152
+ ref={btnRef}
153
+ variant="ghost"
154
+ size="xs"
155
+ aria-haspopup="menu"
156
+ aria-expanded={open}
157
+ aria-label={ariaLabel}
158
+ onClick={(event) => {
159
+ event.stopPropagation()
160
+ setOpen((previous) => !previous)
161
+ requestAnimationFrame(updatePosition)
162
+ }}
163
+ className="inline-flex size-4 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
164
+ >
165
+ <MoreVertical className="size-3.5" aria-hidden="true" />
166
+ </IconButton>
167
+ {open && anchor && typeof document !== 'undefined'
168
+ ? createPortal(
169
+ <div
170
+ ref={menuRef}
171
+ role="menu"
172
+ onKeyDown={handleMenuKeyDown}
173
+ className="fixed z-dropdown w-52 max-w-[calc(100vw-1rem)] rounded-md border border-border bg-background p-1.5 shadow-md"
174
+ style={{
175
+ top:
176
+ direction === 'down'
177
+ ? anchor.bottom + MENU_GAP
178
+ : anchor.top - MENU_GAP,
179
+ left: Math.min(anchor.right, window.innerWidth - 8),
180
+ transform: `translate(-100%, ${direction === 'down' ? '0' : '-100%'})`,
181
+ }}
182
+ >
183
+ {items.map((item, idx) => {
184
+ const shortcut = item.id ? ITEM_SHORTCUTS[item.id] : null
185
+ return (
186
+ <Button
187
+ variant={item.destructive ? 'destructive-ghost' : 'ghost'}
188
+ size="sm"
189
+ key={item.id ?? `${item.label}-${idx}`}
190
+ type="button"
191
+ role="menuitem"
192
+ onClick={(event) => {
193
+ event.stopPropagation()
194
+ setOpen(false)
195
+ item.onSelect?.()
196
+ }}
197
+ className={`flex w-full items-center justify-start rounded-sm px-3 py-2 text-left text-sm leading-5 transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none ${
198
+ item.destructive ? 'text-status-error-text hover:bg-status-error-bg/30' : 'text-foreground'
199
+ }`}
200
+ >
201
+ <span className="flex-1 truncate">{item.label}</span>
202
+ {shortcut ? <MenuShortcut>{shortcut}</MenuShortcut> : null}
203
+ </Button>
204
+ )
205
+ })}
206
+ </div>,
207
+ document.body,
208
+ )
209
+ : null}
210
+ </div>
211
+ )
212
+ }
213
+
214
+ export default DealCardMenu
@@ -0,0 +1,257 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Check, Search } from 'lucide-react'
5
+ import {
6
+ Popover,
7
+ PopoverContent,
8
+ PopoverTrigger,
9
+ } from '@open-mercato/ui/primitives/popover'
10
+ import { Button } from '@open-mercato/ui/primitives/button'
11
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
12
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
13
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
14
+ import { ChipButton } from './ChipButton'
15
+ import { FilterPopoverShell } from './FilterPopoverShell'
16
+
17
+ export type EntityFilterOption = {
18
+ value: string
19
+ label: string
20
+ }
21
+
22
+ type EntityFilterPopoverProps = {
23
+ /** Chip label (e.g. "Owner") */
24
+ label: string
25
+ /** Currently selected ids */
26
+ values: string[]
27
+ /** Apply selected ids back to parent */
28
+ onApply: (next: string[]) => void
29
+ /** Optional initial set of options to show before the user types */
30
+ initialOptions?: EntityFilterOption[]
31
+ /** Async loader called whenever the search query changes (debounced) */
32
+ loadOptions: (query: string, signal: AbortSignal) => Promise<EntityFilterOption[]>
33
+ /** Labels for selected ids — used to display the chip value when only ids are known */
34
+ labelById?: Record<string, string>
35
+ /** Placeholder shown when nothing is selected */
36
+ anyLabel?: string
37
+ /** Title for the popover header */
38
+ title?: string
39
+ }
40
+
41
+ const DEBOUNCE_MS = 250
42
+
43
+ /**
44
+ * Reusable async-search multi-select popover used by Owner / People / Companies kanban filter
45
+ * chips. Wraps the shared FilterPopoverShell so the chrome (rounded-2xl container, white header
46
+ * with bold title, muted footer with Cancel + Apply) matches the SPEC-048 mock surfaces for the
47
+ * Status (1045:11861) and Pipeline (1045:11917) popovers.
48
+ */
49
+ export function EntityFilterPopover({
50
+ label,
51
+ values,
52
+ onApply,
53
+ initialOptions,
54
+ loadOptions,
55
+ labelById,
56
+ anyLabel,
57
+ title,
58
+ }: EntityFilterPopoverProps): React.ReactElement {
59
+ const t = useT()
60
+ const [open, setOpen] = React.useState(false)
61
+ const [draft, setDraft] = React.useState<string[]>(values)
62
+ const [query, setQuery] = React.useState('')
63
+ const [options, setOptions] = React.useState<EntityFilterOption[]>(initialOptions ?? [])
64
+ const [isLoading, setIsLoading] = React.useState(false)
65
+
66
+ // Reset draft to current applied state whenever the popover opens
67
+ React.useEffect(() => {
68
+ if (open) {
69
+ setDraft(values)
70
+ setQuery('')
71
+ setOptions(initialOptions ?? [])
72
+ }
73
+ }, [open, values, initialOptions])
74
+
75
+ // Debounced async option loader
76
+ React.useEffect(() => {
77
+ if (!open) return
78
+ const controller = new AbortController()
79
+ const handle = window.setTimeout(async () => {
80
+ setIsLoading(true)
81
+ try {
82
+ const items = await loadOptions(query, controller.signal)
83
+ setOptions(items)
84
+ } catch (err) {
85
+ if (controller.signal.aborted) return
86
+ } finally {
87
+ setIsLoading(false)
88
+ }
89
+ }, DEBOUNCE_MS)
90
+ return () => {
91
+ window.clearTimeout(handle)
92
+ controller.abort()
93
+ }
94
+ }, [open, query, loadOptions])
95
+
96
+ const chipValue = React.useMemo(() => {
97
+ if (values.length === 0) {
98
+ return anyLabel ?? translateWithFallback(t, 'customers.deals.kanban.filter.any', 'Any')
99
+ }
100
+ if (values.length === 1) {
101
+ const id = values[0]
102
+ const fromLabels = labelById?.[id]
103
+ if (fromLabels) return fromLabels
104
+ const fromOptions = options.find((option) => option.value === id)?.label
105
+ if (fromOptions) return fromOptions
106
+ return id.slice(0, 8)
107
+ }
108
+ return translateWithFallback(
109
+ t,
110
+ 'customers.deals.kanban.filter.multipleSelected',
111
+ '{count} selected',
112
+ { count: values.length },
113
+ )
114
+ }, [values, anyLabel, labelById, options, t])
115
+
116
+ const toggleDraft = (id: string) => {
117
+ setDraft((prev) => (prev.includes(id) ? prev.filter((value) => value !== id) : [...prev, id]))
118
+ }
119
+
120
+ const handleApply = () => {
121
+ onApply(draft)
122
+ setOpen(false)
123
+ }
124
+ const handleClear = () => {
125
+ setDraft([])
126
+ }
127
+
128
+ // Cmd/Ctrl+Enter from anywhere inside the popover confirms — parity with the dialog
129
+ // primary-action shortcut (`AGENTS.md` UI Interaction rules). Also works while typing
130
+ // in the search box because keydown bubbles up to the PopoverContent root.
131
+ const handleKeyDown = (event: React.KeyboardEvent) => {
132
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
133
+ event.preventDefault()
134
+ handleApply()
135
+ }
136
+ }
137
+
138
+ const headerTitle = title ?? translateWithFallback(
139
+ t,
140
+ 'customers.deals.kanban.filter.title',
141
+ 'Filter · {label}',
142
+ { label },
143
+ )
144
+
145
+ const footerLeft = (
146
+ <div className="flex items-center gap-3">
147
+ <span>
148
+ {draft.length === 0
149
+ ? translateWithFallback(t, 'customers.deals.kanban.filter.noneSelected', 'None selected')
150
+ : translateWithFallback(
151
+ t,
152
+ 'customers.deals.kanban.filter.countSelected',
153
+ '{count} selected',
154
+ { count: draft.length },
155
+ )}
156
+ </span>
157
+ {draft.length > 0 ? (
158
+ <Button
159
+ type="button"
160
+ variant="link"
161
+ size="2xs"
162
+ onClick={handleClear}
163
+ className="h-auto px-0 text-xs font-medium leading-normal text-muted-foreground hover:text-foreground"
164
+ >
165
+ {translateWithFallback(t, 'customers.deals.kanban.filter.clear', 'Clear')}
166
+ </Button>
167
+ ) : null}
168
+ </div>
169
+ )
170
+
171
+ return (
172
+ <Popover open={open} onOpenChange={setOpen}>
173
+ <PopoverTrigger asChild>
174
+ <ChipButton label={label} value={chipValue} active={values.length > 0} />
175
+ </PopoverTrigger>
176
+ <PopoverContent
177
+ className="w-96 rounded-2xl border-border bg-transparent p-0 shadow-xl"
178
+ align="start"
179
+ onKeyDown={handleKeyDown}
180
+ >
181
+ <FilterPopoverShell
182
+ title={headerTitle}
183
+ onClose={() => setOpen(false)}
184
+ onCancel={() => setOpen(false)}
185
+ onApply={handleApply}
186
+ footerLeft={footerLeft}
187
+ bodyClassName="flex flex-col bg-card"
188
+ >
189
+ <div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
190
+ <Search className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
191
+ <input
192
+ type="text"
193
+ value={query}
194
+ onChange={(e) => setQuery(e.target.value)}
195
+ placeholder={translateWithFallback(
196
+ t,
197
+ 'customers.deals.kanban.filter.searchPlaceholder',
198
+ 'Search…',
199
+ )}
200
+ className="h-7 w-full bg-transparent text-sm leading-normal text-foreground outline-none placeholder:text-muted-foreground"
201
+ autoFocus
202
+ />
203
+ {isLoading ? <Spinner className="size-3" /> : null}
204
+ </div>
205
+
206
+ <div className="max-h-64 overflow-y-auto">
207
+ {options.length === 0 && !isLoading ? (
208
+ <div className="px-4 py-6 text-center text-xs text-muted-foreground">
209
+ {translateWithFallback(t, 'customers.deals.kanban.filter.noResults', 'No matches')}
210
+ </div>
211
+ ) : (
212
+ <ul className="py-1">
213
+ {options.map((option) => {
214
+ const checked = draft.includes(option.value)
215
+ return (
216
+ <li key={option.value}>
217
+ <Button
218
+ type="button"
219
+ variant="ghost"
220
+ role="checkbox"
221
+ aria-checked={checked}
222
+ onClick={() => toggleDraft(option.value)}
223
+ className={`h-auto w-full justify-start gap-3 rounded-none px-4 py-2.5 text-left font-normal ${
224
+ checked ? 'bg-muted/60' : 'bg-card'
225
+ }`}
226
+ >
227
+ <span
228
+ className={`flex size-4 shrink-0 items-center justify-center rounded-sm border ${
229
+ checked
230
+ ? 'border-accent-indigo bg-accent-indigo text-accent-indigo-foreground'
231
+ : 'border-input bg-card'
232
+ }`}
233
+ aria-hidden="true"
234
+ >
235
+ {checked ? <Check className="size-3" /> : null}
236
+ </span>
237
+ <span
238
+ className={`truncate text-sm leading-normal text-foreground ${
239
+ checked ? 'font-semibold' : 'font-normal'
240
+ }`}
241
+ >
242
+ {option.label}
243
+ </span>
244
+ </Button>
245
+ </li>
246
+ )
247
+ })}
248
+ </ul>
249
+ )}
250
+ </div>
251
+ </FilterPopoverShell>
252
+ </PopoverContent>
253
+ </Popover>
254
+ )
255
+ }
256
+
257
+ export default EntityFilterPopover
@@ -0,0 +1,62 @@
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
+ import { ChipButton } from './ChipButton'
7
+
8
+ export type KanbanFilterChip = {
9
+ id: 'owner' | 'people' | 'companies' | 'close'
10
+ label: string
11
+ value: string
12
+ }
13
+
14
+ type FilterBarRowProps = {
15
+ /** Popover-backed chips (Status, Pipeline) rendered first, in order */
16
+ leadingChips: React.ReactNode
17
+ /** Static (Phase-5-stubbed) chips */
18
+ chips: KanbanFilterChip[]
19
+ /** Right-aligned sort chip (popover-backed) */
20
+ sortNode: React.ReactNode
21
+ onChipClick: (chipId: KanbanFilterChip['id']) => void
22
+ }
23
+
24
+ export function FilterBarRow({
25
+ leadingChips,
26
+ chips,
27
+ sortNode,
28
+ onChipClick,
29
+ }: FilterBarRowProps): React.ReactElement {
30
+ const t = useT()
31
+ // Note: the Figma mock includes a "+ Add filter" chip (1045:11987) that opens a picker for
32
+ // Tags / Source / Currency / Created date / Updated date / Last activity / custom fields.
33
+ // That picker is not wired up in this slice (it would need AdvancedFilterPanel integration),
34
+ // so the affordance is intentionally omitted instead of shipping a "coming next" toast.
35
+ return (
36
+ <div className="flex flex-wrap items-center justify-between gap-y-2 rounded-lg bg-muted/40 px-3.5 py-3">
37
+ <div className="flex flex-wrap items-center gap-2">
38
+ <span className="text-overline font-bold uppercase leading-normal text-muted-foreground">
39
+ {translateWithFallback(t, 'customers.deals.kanban.filter.label', 'Filter')}:
40
+ </span>
41
+ {leadingChips}
42
+ {chips.map((chip) => (
43
+ <ChipButton
44
+ key={chip.id}
45
+ label={chip.label}
46
+ value={chip.value}
47
+ onClick={() => onChipClick(chip.id)}
48
+ ariaLabel={translateWithFallback(
49
+ t,
50
+ 'customers.deals.kanban.filter.aria.chip',
51
+ 'Filter by {label}',
52
+ { label: chip.label },
53
+ )}
54
+ />
55
+ ))}
56
+ </div>
57
+ <div className="ml-auto">{sortNode}</div>
58
+ </div>
59
+ )
60
+ }
61
+
62
+ export default FilterBarRow
@@ -0,0 +1,101 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { X } from 'lucide-react'
5
+ import { Button } from '@open-mercato/ui/primitives/button'
6
+ import { IconButton } from '@open-mercato/ui/primitives/icon-button'
7
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
8
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
9
+
10
+ type FilterPopoverShellProps = {
11
+ /** Title rendered in the header (e.g. "Filter · Status", "Sort by") */
12
+ title: React.ReactNode
13
+ /** Optional icon rendered before the title (Sort by uses a tag icon per Figma) */
14
+ leadingIcon?: React.ReactNode
15
+ /** Body content — sits between header and footer */
16
+ children: React.ReactNode
17
+ /** Footer left content — usually a "N selected" hint or "Default: …" subtitle */
18
+ footerLeft?: React.ReactNode
19
+ /** Called when the X close button is clicked */
20
+ onClose: () => void
21
+ /** Called when Cancel is clicked */
22
+ onCancel: () => void
23
+ /** Called when Apply (primary CTA) is clicked */
24
+ onApply: () => void
25
+ /** Override the primary CTA label (e.g. "Add" on the +Add-filter popover) */
26
+ applyLabel?: string
27
+ /** Body wrapper className override — defaults to standard padded white block */
28
+ bodyClassName?: string
29
+ }
30
+
31
+ /**
32
+ * Shared chrome for every kanban filter popover (Status, Pipeline, Sort, Owner, People,
33
+ * Companies, Close). Matches the SPEC-048 Figma popover surfaces (nodes 1045:11861,
34
+ * 1045:11917, 1045:12090): rounded-2xl outer, white header w/ bold title + close button,
35
+ * white body, muted footer with Cancel + Apply.
36
+ *
37
+ * Each consumer renders its own body — checkboxes, pills, radio rows, date picker, etc. —
38
+ * inside this shell, so the chrome stays identical across every filter surface and the
39
+ * styling delta lives only inside `children`.
40
+ */
41
+ export function FilterPopoverShell({
42
+ title,
43
+ leadingIcon,
44
+ children,
45
+ footerLeft,
46
+ onClose,
47
+ onCancel,
48
+ onApply,
49
+ applyLabel,
50
+ bodyClassName,
51
+ }: FilterPopoverShellProps): React.ReactElement {
52
+ const t = useT()
53
+ return (
54
+ <div className="flex flex-col overflow-hidden rounded-2xl bg-card">
55
+ <div className="flex items-center justify-between border-b border-border bg-card px-5 py-4">
56
+ <div className="flex items-center gap-2">
57
+ {leadingIcon ? (
58
+ <span className="flex size-4 items-center justify-center text-muted-foreground" aria-hidden="true">
59
+ {leadingIcon}
60
+ </span>
61
+ ) : null}
62
+ <span className="text-base font-bold leading-normal text-foreground">{title}</span>
63
+ </div>
64
+ <IconButton
65
+ type="button"
66
+ variant="outline"
67
+ size="sm"
68
+ onClick={onClose}
69
+ aria-label={translateWithFallback(t, 'customers.deals.kanban.filter.close', 'Close')}
70
+ >
71
+ <X className="size-3.5" aria-hidden="true" />
72
+ </IconButton>
73
+ </div>
74
+
75
+ <div className={bodyClassName ?? 'flex flex-col gap-4 bg-card px-5 py-4'}>{children}</div>
76
+
77
+ <div className="flex items-center justify-between border-t border-border bg-muted px-5 py-3.5">
78
+ <div className="text-xs leading-normal text-muted-foreground">{footerLeft ?? null}</div>
79
+ <div className="flex items-center gap-6">
80
+ <Button
81
+ variant="outline"
82
+ type="button"
83
+ onClick={onCancel}
84
+ className="h-auto rounded-lg border-input bg-card px-4 py-2 text-sm font-semibold text-foreground"
85
+ >
86
+ {translateWithFallback(t, 'customers.deals.kanban.filter.cancel', 'Cancel')}
87
+ </Button>
88
+ <Button
89
+ type="button"
90
+ onClick={onApply}
91
+ className="h-auto rounded-lg bg-foreground px-5 py-2 text-sm font-semibold text-background hover:bg-foreground/90"
92
+ >
93
+ {applyLabel ?? translateWithFallback(t, 'customers.deals.kanban.filter.apply', 'Apply')}
94
+ </Button>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ )
99
+ }
100
+
101
+ export default FilterPopoverShell