@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,232 @@
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 { useT } from '@open-mercato/shared/lib/i18n/context'
11
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
12
+ import { ChipButton } from './ChipButton'
13
+ import { FilterPopoverShell } from './FilterPopoverShell'
14
+
15
+ export type CloseDateRange = {
16
+ from: string | null
17
+ to: string | null
18
+ }
19
+
20
+ type CloseDateFilterPopoverProps = {
21
+ value: CloseDateRange
22
+ onApply: (next: CloseDateRange) => void
23
+ }
24
+
25
+ type PresetKey = 'next-30' | 'next-90' | 'this-month' | 'this-quarter' | 'overdue' | 'custom'
26
+
27
+ function ymd(date: Date): string {
28
+ return date.toISOString().slice(0, 10)
29
+ }
30
+
31
+ function startOfMonth(): Date {
32
+ const d = new Date()
33
+ d.setUTCHours(0, 0, 0, 0)
34
+ d.setUTCDate(1)
35
+ return d
36
+ }
37
+
38
+ function endOfMonth(): Date {
39
+ const d = startOfMonth()
40
+ d.setUTCMonth(d.getUTCMonth() + 1)
41
+ d.setUTCDate(0)
42
+ return d
43
+ }
44
+
45
+ function startOfQuarter(): Date {
46
+ const d = new Date()
47
+ d.setUTCHours(0, 0, 0, 0)
48
+ d.setUTCDate(1)
49
+ const m = d.getUTCMonth()
50
+ d.setUTCMonth(m - (m % 3))
51
+ return d
52
+ }
53
+
54
+ function endOfQuarter(): Date {
55
+ const d = startOfQuarter()
56
+ d.setUTCMonth(d.getUTCMonth() + 3)
57
+ d.setUTCDate(0)
58
+ return d
59
+ }
60
+
61
+ function buildPresetRange(key: PresetKey): CloseDateRange {
62
+ const now = new Date()
63
+ now.setUTCHours(0, 0, 0, 0)
64
+ switch (key) {
65
+ case 'next-30': {
66
+ const to = new Date(now)
67
+ to.setUTCDate(to.getUTCDate() + 30)
68
+ return { from: ymd(now), to: ymd(to) }
69
+ }
70
+ case 'next-90': {
71
+ const to = new Date(now)
72
+ to.setUTCDate(to.getUTCDate() + 90)
73
+ return { from: ymd(now), to: ymd(to) }
74
+ }
75
+ case 'this-month':
76
+ return { from: ymd(startOfMonth()), to: ymd(endOfMonth()) }
77
+ case 'this-quarter':
78
+ return { from: ymd(startOfQuarter()), to: ymd(endOfQuarter()) }
79
+ case 'overdue': {
80
+ const yesterday = new Date(now)
81
+ yesterday.setUTCDate(yesterday.getUTCDate() - 1)
82
+ return { from: null, to: ymd(yesterday) }
83
+ }
84
+ default:
85
+ return { from: null, to: null }
86
+ }
87
+ }
88
+
89
+ export function CloseDateFilterPopover({ value, onApply }: CloseDateFilterPopoverProps): React.ReactElement {
90
+ const t = useT()
91
+ const [open, setOpen] = React.useState(false)
92
+ const [draft, setDraft] = React.useState<CloseDateRange>(value)
93
+
94
+ React.useEffect(() => {
95
+ if (open) setDraft(value)
96
+ }, [open, value])
97
+
98
+ const isActive = !!(value.from || value.to)
99
+ const chipLabel = translateWithFallback(t, 'customers.deals.kanban.filter.close', 'Close')
100
+ const chipValue = React.useMemo(() => {
101
+ // Show "Any" (not "Next 90 days") when no filter is applied — the previous label was a
102
+ // preset name, which read like the filter was already active and confused operators.
103
+ if (!isActive) return translateWithFallback(t, 'customers.deals.kanban.filter.any', 'Any')
104
+ if (value.from && value.to) {
105
+ const f = new Date(value.from).toLocaleDateString(undefined, { month: 'short', day: '2-digit' })
106
+ const tt = new Date(value.to).toLocaleDateString(undefined, { month: 'short', day: '2-digit' })
107
+ return `${f} – ${tt}`
108
+ }
109
+ if (value.from && !value.to)
110
+ return translateWithFallback(t, 'customers.deals.kanban.filter.from', 'From {date}', {
111
+ date: new Date(value.from).toLocaleDateString(undefined, { month: 'short', day: '2-digit' }),
112
+ })
113
+ if (!value.from && value.to)
114
+ return translateWithFallback(t, 'customers.deals.kanban.filter.until', 'Until {date}', {
115
+ date: new Date(value.to).toLocaleDateString(undefined, { month: 'short', day: '2-digit' }),
116
+ })
117
+ return ''
118
+ }, [value.from, value.to, isActive, t])
119
+
120
+ const applyPreset = (key: PresetKey) => setDraft(buildPresetRange(key))
121
+
122
+ const handleApply = () => {
123
+ onApply({
124
+ from: draft.from && draft.from.length ? draft.from : null,
125
+ to: draft.to && draft.to.length ? draft.to : null,
126
+ })
127
+ setOpen(false)
128
+ }
129
+ const handleClear = () => setDraft({ from: null, to: null })
130
+
131
+ // Cmd/Ctrl+Enter from anywhere inside the popover confirms — parity with the dialog
132
+ // primary-action shortcut (`AGENTS.md` UI Interaction rules).
133
+ const handleKeyDown = (event: React.KeyboardEvent) => {
134
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
135
+ event.preventDefault()
136
+ handleApply()
137
+ }
138
+ }
139
+
140
+ const hasDraft = !!(draft.from || draft.to)
141
+ const footerLeft = (
142
+ <div className="flex items-center gap-3">
143
+ <span>
144
+ {hasDraft
145
+ ? translateWithFallback(t, 'customers.deals.kanban.filter.dateRange.applied', 'Range set')
146
+ : translateWithFallback(t, 'customers.deals.kanban.filter.any', 'Any')}
147
+ </span>
148
+ {hasDraft ? (
149
+ <Button
150
+ variant="link"
151
+ size="sm"
152
+ type="button"
153
+ onClick={handleClear}
154
+ className="h-auto px-0 py-0 text-xs font-medium leading-normal"
155
+ >
156
+ {translateWithFallback(t, 'customers.deals.kanban.filter.clear', 'Clear')}
157
+ </Button>
158
+ ) : null}
159
+ </div>
160
+ )
161
+
162
+ return (
163
+ <Popover open={open} onOpenChange={setOpen}>
164
+ <PopoverTrigger asChild>
165
+ <ChipButton label={chipLabel} value={chipValue} active={isActive} />
166
+ </PopoverTrigger>
167
+ <PopoverContent
168
+ className="w-80 rounded-2xl border-border bg-transparent p-0 shadow-xl"
169
+ align="start"
170
+ onKeyDown={handleKeyDown}
171
+ >
172
+ <FilterPopoverShell
173
+ title={translateWithFallback(t, 'customers.deals.kanban.filter.close.title', 'Filter · Expected close')}
174
+ onClose={() => setOpen(false)}
175
+ onCancel={() => setOpen(false)}
176
+ onApply={handleApply}
177
+ footerLeft={footerLeft}
178
+ >
179
+ <div className="flex flex-wrap gap-1.5">
180
+ {(
181
+ [
182
+ { key: 'next-30', label: translateWithFallback(t, 'customers.deals.kanban.filter.preset.next30', 'Next 30 days') },
183
+ { key: 'next-90', label: translateWithFallback(t, 'customers.deals.kanban.filter.preset.next90', 'Next 90 days') },
184
+ { key: 'this-month', label: translateWithFallback(t, 'customers.deals.kanban.filter.preset.thisMonth', 'This month') },
185
+ { key: 'this-quarter', label: translateWithFallback(t, 'customers.deals.kanban.filter.preset.thisQuarter', 'This quarter') },
186
+ { key: 'overdue', label: translateWithFallback(t, 'customers.deals.kanban.filter.preset.overdue', 'Overdue') },
187
+ ] as Array<{ key: PresetKey; label: string }>
188
+ ).map((preset) => (
189
+ <Button
190
+ variant="outline"
191
+ size="sm"
192
+ key={preset.key}
193
+ type="button"
194
+ onClick={() => applyPreset(preset.key)}
195
+ className="h-auto rounded-full border-border bg-card px-2.5 py-1 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground"
196
+ >
197
+ {preset.label}
198
+ </Button>
199
+ ))}
200
+ </div>
201
+
202
+ <div className="grid grid-cols-2 gap-3">
203
+ <label className="flex flex-col gap-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
204
+ {translateWithFallback(t, 'customers.deals.kanban.filter.fromLabel', 'From')}
205
+ <input
206
+ type="date"
207
+ value={draft.from ?? ''}
208
+ onChange={(event) =>
209
+ setDraft((previous) => ({ ...previous, from: event.target.value || null }))
210
+ }
211
+ className="rounded-md border border-input bg-card px-2.5 py-1.5 text-sm font-normal leading-normal text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
212
+ />
213
+ </label>
214
+ <label className="flex flex-col gap-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
215
+ {translateWithFallback(t, 'customers.deals.kanban.filter.toLabel', 'To')}
216
+ <input
217
+ type="date"
218
+ value={draft.to ?? ''}
219
+ onChange={(event) =>
220
+ setDraft((previous) => ({ ...previous, to: event.target.value || null }))
221
+ }
222
+ className="rounded-md border border-input bg-card px-2.5 py-1.5 text-sm font-normal leading-normal text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
223
+ />
224
+ </label>
225
+ </div>
226
+ </FilterPopoverShell>
227
+ </PopoverContent>
228
+ </Popover>
229
+ )
230
+ }
231
+
232
+ export default CloseDateFilterPopover
@@ -0,0 +1,110 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { AlertTriangle } from 'lucide-react'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
7
+
8
+ export type CurrencyBreakdownRow = {
9
+ currency: string
10
+ total: number
11
+ count: number
12
+ }
13
+
14
+ type CurrencyBreakdownTableProps = {
15
+ rows: CurrencyBreakdownRow[]
16
+ baseCurrencyCode: string | null
17
+ /** Currencies with no FX rate to base — surfaced with a warning icon next to the code. */
18
+ missingRateCurrencies: string[]
19
+ }
20
+
21
+ /**
22
+ * Read-only currency breakdown body shared between `LaneCurrencyBreakdown` and any future
23
+ * informational popover. Matches the SPEC-048 Figma node 1251:610 currency table:
24
+ * - Column header row: "Currency · Native total · Deals" (overline gray)
25
+ * - Divider line
26
+ * - Per-currency rows with thin bottom dividers; BASE pill on the base-currency row
27
+ *
28
+ * The interactive filter variant (`CurrencyFilterPopover`) does NOT use this table — its rows
29
+ * are radio selectors with different highlighting — but it reuses the same outer chrome from
30
+ * `LaneCurrencyBreakdown`. Keeping them visually consistent is the responsibility of the
31
+ * Figma source of truth, not of this component.
32
+ */
33
+ export function CurrencyBreakdownTable({
34
+ rows,
35
+ baseCurrencyCode,
36
+ missingRateCurrencies,
37
+ }: CurrencyBreakdownTableProps): React.ReactElement {
38
+ const t = useT()
39
+ const base = baseCurrencyCode?.toUpperCase() ?? null
40
+
41
+ return (
42
+ <div className="flex flex-col bg-card px-6">
43
+ <div className="flex items-center gap-1 pt-5 pb-2">
44
+ <span className="text-overline font-semibold uppercase tracking-wider text-muted-foreground">
45
+ {translateWithFallback(t, 'customers.deals.kanban.currencyBreakdown.colCurrency', 'Currency')}
46
+ </span>
47
+ <span className="flex-1" aria-hidden="true" />
48
+ <span className="text-overline font-semibold uppercase tracking-wider text-muted-foreground">
49
+ {translateWithFallback(
50
+ t,
51
+ 'customers.deals.kanban.currencyBreakdown.colNativeTotal',
52
+ 'Native total',
53
+ )}
54
+ </span>
55
+ <span className="pl-6 text-overline font-semibold uppercase tracking-wider text-muted-foreground">
56
+ {translateWithFallback(t, 'customers.deals.kanban.currencyBreakdown.colDeals', 'Deals')}
57
+ </span>
58
+ </div>
59
+ <div className="border-b border-border" aria-hidden="true" />
60
+
61
+ <ul className="flex flex-col">
62
+ {rows.map((row) => {
63
+ const code = row.currency.toUpperCase()
64
+ const isBase = base !== null && code === base
65
+ const isMissingRate = missingRateCurrencies.includes(row.currency)
66
+ return (
67
+ <li
68
+ key={row.currency}
69
+ className="flex items-center gap-1 border-b border-border py-2.5 last:border-b-0"
70
+ >
71
+ <span className="flex items-center gap-1.5 text-sm font-semibold leading-normal text-foreground">
72
+ {isMissingRate ? (
73
+ <AlertTriangle
74
+ className="size-3.5 text-status-warning-icon"
75
+ aria-hidden="true"
76
+ />
77
+ ) : null}
78
+ <span>{code}</span>
79
+ {isBase ? (
80
+ // text-[9px] is an intentional Figma exception for the BASE badge (node 1251:671).
81
+ // See CurrencyFilterPopover for the same rationale — both share the badge.
82
+ <span className="inline-flex items-center rounded-sm bg-brand-violet/10 px-1.5 py-px text-[9px] font-bold uppercase leading-normal tracking-wider text-brand-violet">
83
+ {translateWithFallback(t, 'customers.deals.kanban.currencyBreakdown.basePill', 'BASE')}
84
+ </span>
85
+ ) : null}
86
+ </span>
87
+ <span className="flex-1" aria-hidden="true" />
88
+ <span className="text-sm font-semibold leading-normal text-foreground tabular-nums">
89
+ {formatAmount(row.total)}
90
+ </span>
91
+ <span className="pl-6 text-sm font-normal leading-normal text-muted-foreground tabular-nums">
92
+ {row.count}
93
+ </span>
94
+ </li>
95
+ )
96
+ })}
97
+ </ul>
98
+ </div>
99
+ )
100
+ }
101
+
102
+ function formatAmount(amount: number): string {
103
+ return new Intl.NumberFormat(undefined, {
104
+ style: 'decimal',
105
+ maximumFractionDigits: 0,
106
+ useGrouping: true,
107
+ }).format(Math.round(amount))
108
+ }
109
+
110
+ export default CurrencyBreakdownTable
@@ -0,0 +1,310 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { X } 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 { IconButton } from '@open-mercato/ui/primitives/icon-button'
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
+
16
+ export type CurrencyFilterRow = {
17
+ currency: string
18
+ total: number
19
+ count: number
20
+ }
21
+
22
+ type CurrencyFilterPopoverProps = {
23
+ /** Per-currency rows scoped to the current pipeline filter context. */
24
+ rows: CurrencyFilterRow[]
25
+ /** Tenant base currency code, or `null` when none is configured. */
26
+ baseCurrencyCode: string | null
27
+ /** Aggregate total across all currencies in `rows`, expressed in `baseCurrencyCode`. */
28
+ totalInBaseCurrency: number
29
+ /** Stage / pipeline label shown in the subtitle (e.g. `"PIPELINE"`). */
30
+ headingLabel: string
31
+ /** Total deal count surfaced in the subtitle. */
32
+ headingCount: number
33
+ /** Currently-applied currency filter (`null` means "All currencies"). */
34
+ selectedCurrency: string | null
35
+ /** Called when the operator presses "Apply filter". `null` clears the filter. */
36
+ onApply: (currency: string | null) => void
37
+ }
38
+
39
+ /**
40
+ * Currency filter popover anchored to a "Currency" chip on the kanban filter bar.
41
+ *
42
+ * Matches SPEC-048 Figma node 1251:699 — header + subtitle + radio rows + Apply filter footer.
43
+ * Selecting a row narrows the kanban board to deals whose `valueCurrency` equals the chosen
44
+ * code; the "All currencies" sentinel clears the filter. The selected row uses the
45
+ * brand-violet tinted background to mirror the Figma highlight.
46
+ *
47
+ * Chrome (rounded-2xl outer, bordered header w/ close, muted footer) matches
48
+ * `LaneCurrencyBreakdown` so the two popovers feel like a family.
49
+ */
50
+ export function CurrencyFilterPopover({
51
+ rows,
52
+ baseCurrencyCode,
53
+ totalInBaseCurrency,
54
+ headingLabel,
55
+ headingCount,
56
+ selectedCurrency,
57
+ onApply,
58
+ }: CurrencyFilterPopoverProps): React.ReactElement {
59
+ const t = useT()
60
+ const [open, setOpen] = React.useState(false)
61
+ const [draft, setDraft] = React.useState<string | null>(selectedCurrency)
62
+
63
+ React.useEffect(() => {
64
+ if (open) setDraft(selectedCurrency)
65
+ }, [open, selectedCurrency])
66
+
67
+ const base = baseCurrencyCode?.toUpperCase() ?? null
68
+ const chipLabel = translateWithFallback(t, 'customers.deals.kanban.currencyFilter.chipLabel', 'Currency')
69
+ const chipValue = selectedCurrency
70
+ ? selectedCurrency.toUpperCase()
71
+ : translateWithFallback(t, 'customers.deals.kanban.filter.all', 'All')
72
+ const closeLabel = translateWithFallback(t, 'customers.deals.kanban.filter.close', 'Close')
73
+ const applyLabel = translateWithFallback(
74
+ t,
75
+ 'customers.deals.kanban.currencyFilter.apply',
76
+ 'Apply filter',
77
+ )
78
+
79
+ const handleApply = () => {
80
+ onApply(draft)
81
+ setOpen(false)
82
+ }
83
+
84
+ // Cmd/Ctrl+Enter parity with other kanban filter popovers (`AGENTS.md` UI Interaction rules).
85
+ const handleKeyDown = (event: React.KeyboardEvent) => {
86
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
87
+ event.preventDefault()
88
+ handleApply()
89
+ }
90
+ }
91
+
92
+ const headingCountLabel = translateWithFallback(
93
+ t,
94
+ 'customers.deals.kanban.currencyBreakdown.headingCount',
95
+ '({count} deals)',
96
+ { count: headingCount },
97
+ )
98
+
99
+ return (
100
+ <Popover open={open} onOpenChange={setOpen}>
101
+ <PopoverTrigger asChild>
102
+ <ChipButton
103
+ label={chipLabel}
104
+ value={chipValue}
105
+ active={selectedCurrency !== null}
106
+ />
107
+ </PopoverTrigger>
108
+ <PopoverContent
109
+ align="start"
110
+ className="w-96 rounded-2xl border-border bg-transparent p-0 shadow-xl"
111
+ onKeyDown={handleKeyDown}
112
+ >
113
+ {/*
114
+ Solid `bg-card` (no opacity modifier) on the outer wrapper — round-2 UX review item 33.
115
+ Floating overlays must not let underlying card content bleed through; the previous
116
+ `bg-muted/30` allowed card titles/badges to show through this popover.
117
+ */}
118
+ <div className="flex flex-col overflow-hidden rounded-2xl bg-card">
119
+ <div className="flex items-center justify-between border-b border-border bg-card px-5 py-4">
120
+ <span className="text-base font-bold leading-normal text-foreground">
121
+ {translateWithFallback(
122
+ t,
123
+ 'customers.deals.kanban.currencyFilter.title',
124
+ 'Filter kanban by currency',
125
+ )}
126
+ </span>
127
+ <IconButton
128
+ variant="ghost"
129
+ size="xs"
130
+ onClick={() => setOpen(false)}
131
+ aria-label={closeLabel}
132
+ 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"
133
+ >
134
+ <X className="size-3.5" aria-hidden="true" />
135
+ </IconButton>
136
+ </div>
137
+
138
+ <div className="flex flex-col bg-card pt-3.5">
139
+ <div className="px-6 pb-2">
140
+ <span className="text-overline font-semibold uppercase tracking-wider text-muted-foreground">
141
+ {translateWithFallback(
142
+ t,
143
+ 'customers.deals.kanban.currencyFilter.subtitle',
144
+ '{label} · {count} deals · {total} {currency}',
145
+ {
146
+ label: headingLabel,
147
+ count: headingCount,
148
+ total: formatAmount(totalInBaseCurrency),
149
+ currency: baseCurrencyCode ?? '',
150
+ },
151
+ )}
152
+ </span>
153
+ </div>
154
+
155
+ <div className="flex flex-col">
156
+ <CurrencyRow
157
+ isAllCurrencies
158
+ selected={draft === null}
159
+ onClick={() => setDraft(null)}
160
+ label={translateWithFallback(
161
+ t,
162
+ 'customers.deals.kanban.currencyFilter.allCurrencies',
163
+ 'All currencies',
164
+ )}
165
+ amount={totalInBaseCurrency}
166
+ amountCurrency={baseCurrencyCode ?? ''}
167
+ count={headingCount}
168
+ />
169
+ {rows.map((row) => {
170
+ const code = row.currency.toUpperCase()
171
+ const isBase = base !== null && code === base
172
+ const isSelected = draft === code
173
+ return (
174
+ <CurrencyRow
175
+ key={row.currency}
176
+ selected={isSelected}
177
+ onClick={() => setDraft(code)}
178
+ label={code}
179
+ showBasePill={isBase}
180
+ amount={row.total}
181
+ amountCurrency={code}
182
+ count={row.count}
183
+ />
184
+ )
185
+ })}
186
+ </div>
187
+
188
+ <div className="px-6 pt-3 pb-3.5">
189
+ <p className="text-xs font-normal leading-normal text-muted-foreground">
190
+ {translateWithFallback(
191
+ t,
192
+ 'customers.deals.kanban.currencyFilter.footerCredit',
193
+ 'NBP mid conversion rate · {date}',
194
+ { date: formatToday() },
195
+ )}
196
+ </p>
197
+ </div>
198
+ </div>
199
+
200
+ {/* Footer uses solid `bg-muted` (no opacity modifier) — round-2 UX review item 33. */}
201
+ <div className="flex items-center justify-end border-t border-border bg-muted px-5 py-3.5">
202
+ <Button
203
+ type="button"
204
+ onClick={handleApply}
205
+ className="h-auto rounded-lg bg-foreground px-5 py-2 text-sm font-semibold text-background hover:bg-foreground/90"
206
+ >
207
+ {applyLabel}
208
+ </Button>
209
+ </div>
210
+ </div>
211
+ </PopoverContent>
212
+ </Popover>
213
+ )
214
+ }
215
+
216
+ type CurrencyRowProps = {
217
+ /** Marks the row as the "All currencies" sentinel so we suppress the per-row currency suffix. */
218
+ isAllCurrencies?: boolean
219
+ selected: boolean
220
+ onClick: () => void
221
+ label: string
222
+ showBasePill?: boolean
223
+ amount: number
224
+ amountCurrency: string
225
+ count: number
226
+ }
227
+
228
+ function CurrencyRow({
229
+ isAllCurrencies = false,
230
+ selected,
231
+ onClick,
232
+ label,
233
+ showBasePill = false,
234
+ amount,
235
+ amountCurrency,
236
+ count,
237
+ }: CurrencyRowProps): React.ReactElement {
238
+ const t = useT()
239
+ const rowBg = selected ? 'bg-brand-violet/10' : 'bg-card hover:bg-muted'
240
+ const codeColor = selected ? 'text-brand-violet' : 'text-foreground'
241
+ const amountColor = selected ? 'text-brand-violet font-semibold' : 'text-foreground font-normal'
242
+ const currencyColor = selected ? 'text-brand-violet/80' : 'text-muted-foreground'
243
+ const countColor = selected ? 'text-brand-violet font-semibold' : 'text-foreground/80 font-normal'
244
+ return (
245
+ <Button
246
+ type="button"
247
+ variant="ghost"
248
+ role="radio"
249
+ aria-checked={selected}
250
+ onClick={onClick}
251
+ className={`h-auto w-full justify-start gap-3 rounded-none px-6 py-2.5 text-left font-normal ${rowBg}`}
252
+ >
253
+ <RadioDot selected={selected} />
254
+ <span className="flex items-center gap-1.5">
255
+ <span className={`text-sm leading-normal ${codeColor} ${selected ? 'font-semibold' : 'font-normal'}`}>
256
+ {label}
257
+ </span>
258
+ {showBasePill ? (
259
+ // text-[9px] is an intentional Figma exception for the BASE badge (node 1251:671).
260
+ // The DS scale bottoms out at text-xs (12px); this 9px is design-system tracked as
261
+ // a tiny-badge exception and should not be migrated to text-overline.
262
+ <span className="inline-flex items-center rounded-sm bg-muted px-1.5 py-px text-[9px] font-bold uppercase leading-normal tracking-wider text-muted-foreground">
263
+ {translateWithFallback(t, 'customers.deals.kanban.currencyBreakdown.basePill', 'BASE')}
264
+ </span>
265
+ ) : null}
266
+ </span>
267
+ <span className="flex-1" aria-hidden="true" />
268
+ <span className="flex items-baseline gap-1">
269
+ <span className={`text-sm leading-normal tabular-nums ${amountColor}`}>
270
+ {formatAmount(amount)}
271
+ </span>
272
+ {!isAllCurrencies || amountCurrency ? (
273
+ <span className={`text-xs leading-normal ${currencyColor}`}>{amountCurrency}</span>
274
+ ) : null}
275
+ </span>
276
+ <span className={`pl-5 text-xs leading-normal tabular-nums ${countColor}`}>{count}</span>
277
+ </Button>
278
+ )
279
+ }
280
+
281
+ function RadioDot({ selected }: { selected: boolean }): React.ReactElement {
282
+ return (
283
+ <span
284
+ className={`flex size-4 shrink-0 items-center justify-center rounded-full border ${
285
+ selected ? 'border-brand-violet' : 'border-input bg-card'
286
+ }`}
287
+ aria-hidden="true"
288
+ >
289
+ {selected ? <span className="size-2 rounded-full bg-brand-violet" /> : null}
290
+ </span>
291
+ )
292
+ }
293
+
294
+ function formatAmount(amount: number): string {
295
+ return new Intl.NumberFormat(undefined, {
296
+ style: 'decimal',
297
+ maximumFractionDigits: 0,
298
+ useGrouping: true,
299
+ }).format(Math.round(amount))
300
+ }
301
+
302
+ function formatToday(): string {
303
+ return new Intl.DateTimeFormat(undefined, {
304
+ year: 'numeric',
305
+ month: 'short',
306
+ day: '2-digit',
307
+ }).format(new Date())
308
+ }
309
+
310
+ export default CurrencyFilterPopover