@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,31 @@
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 { DashedTileButton } from './DashedTileButton'
7
+ import { LANE_WIDTH_CLASS } from './constants'
8
+
9
+ type AddStageLaneProps = {
10
+ onClick: () => void
11
+ disabled?: boolean
12
+ }
13
+
14
+ export function AddStageLane({ onClick, disabled = false }: AddStageLaneProps): React.ReactElement {
15
+ const t = useT()
16
+ const label = translateWithFallback(t, 'customers.deals.kanban.cta.newStage', 'New stage')
17
+
18
+ return (
19
+ <DashedTileButton
20
+ onClick={onClick}
21
+ disabled={disabled}
22
+ ariaLabel={label}
23
+ className={`${LANE_WIDTH_CLASS} flex-none flex-col rounded-xl px-3.5 py-5 text-muted-foreground hover:text-foreground min-h-[60vh]`}
24
+ >
25
+ <span className="text-3xl font-bold leading-none">+</span>
26
+ <span className="text-base font-semibold leading-normal">{label}</span>
27
+ </DashedTileButton>
28
+ )
29
+ }
30
+
31
+ export default AddStageLane
@@ -0,0 +1,171 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Download, Trash2, Workflow, UserCircle2, 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 BulkActionsBarProps = {
11
+ count: number
12
+ totalLabel: string | null
13
+ onChangeStage: () => void
14
+ onChangeOwner: () => void
15
+ onExportCsv: () => void
16
+ onDelete: () => void
17
+ onClear: () => void
18
+ }
19
+
20
+ export function BulkActionsBar({
21
+ count,
22
+ totalLabel,
23
+ onChangeStage,
24
+ onChangeOwner,
25
+ onExportCsv,
26
+ onDelete,
27
+ onClear,
28
+ }: BulkActionsBarProps): React.ReactElement | null {
29
+ const t = useT()
30
+ // Global ESC clears the selection — matches the Gmail/Asana/Notion convention. The
31
+ // listener is only mounted while at least one deal is selected so we don't intercept
32
+ // ESC anywhere else on the kanban (e.g. inside dialogs/popovers, which all stopPropagation
33
+ // before their own ESC handlers fire). `count` is the only thing that gates "is the bar
34
+ // currently rendered?", and `onClear` is the page-level handler that empties the Set.
35
+ React.useEffect(() => {
36
+ if (count === 0) return
37
+ const onKey = (event: KeyboardEvent) => {
38
+ if (event.key !== 'Escape') return
39
+ // Defer if the focus is inside an editable surface — typing ESC to abort a value
40
+ // edit shouldn't also clear the bulk selection.
41
+ const active = document.activeElement as HTMLElement | null
42
+ if (active) {
43
+ const tag = active.tagName
44
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || active.isContentEditable) return
45
+ }
46
+ event.preventDefault()
47
+ onClear()
48
+ }
49
+ document.addEventListener('keydown', onKey)
50
+ return () => document.removeEventListener('keydown', onKey)
51
+ }, [count, onClear])
52
+
53
+ if (count === 0) return null
54
+
55
+ return (
56
+ <div
57
+ role="region"
58
+ aria-label={translateWithFallback(
59
+ t,
60
+ 'customers.deals.kanban.bulk.aria.bar',
61
+ 'Bulk actions',
62
+ )}
63
+ className="fixed bottom-6 left-1/2 z-toast flex w-[min(960px,calc(100%-2rem))] -translate-x-1/2 items-center gap-3 rounded-lg bg-foreground px-4 py-3 text-background shadow-xl"
64
+ >
65
+ <IconButton
66
+ variant="ghost"
67
+ size="xs"
68
+ onClick={onClear}
69
+ aria-label={translateWithFallback(
70
+ t,
71
+ 'customers.deals.kanban.bulk.aria.clear',
72
+ 'Clear selection',
73
+ )}
74
+ className="inline-flex size-6 items-center justify-center rounded-md transition-colors hover:bg-background/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-foreground"
75
+ >
76
+ <X className="size-4" aria-hidden="true" />
77
+ </IconButton>
78
+
79
+ <span className="inline-flex size-7 items-center justify-center rounded-md bg-background/10 text-xs font-semibold">
80
+ {count}
81
+ </span>
82
+ <span className="text-sm font-medium">
83
+ {translateWithFallback(
84
+ t,
85
+ 'customers.deals.kanban.bulk.selectedCount',
86
+ 'selected',
87
+ )}
88
+ </span>
89
+ {totalLabel ? (
90
+ <span className="hidden text-sm text-background/80 sm:inline">
91
+ ·{' '}
92
+ {translateWithFallback(
93
+ t,
94
+ 'customers.deals.kanban.bulk.totalValue',
95
+ 'Total {value}',
96
+ { value: totalLabel },
97
+ )}
98
+ </span>
99
+ ) : null}
100
+
101
+ <div className="ml-auto flex items-center gap-1">
102
+ <Button
103
+ variant="ghost"
104
+ size="sm"
105
+ type="button"
106
+ onClick={onChangeStage}
107
+ className="inline-flex items-center gap-1 rounded-md px-3 py-1 text-sm transition-colors hover:bg-background/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-foreground"
108
+ >
109
+ <Workflow className="size-4" aria-hidden="true" />
110
+ <span>
111
+ {translateWithFallback(
112
+ t,
113
+ 'customers.deals.kanban.bulk.changeStage',
114
+ 'Change stage',
115
+ )}
116
+ </span>
117
+ </Button>
118
+ <Button
119
+ variant="ghost"
120
+ size="sm"
121
+ type="button"
122
+ onClick={onChangeOwner}
123
+ className="inline-flex items-center gap-1 rounded-md px-3 py-1 text-sm transition-colors hover:bg-background/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-foreground"
124
+ >
125
+ <UserCircle2 className="size-4" aria-hidden="true" />
126
+ <span>
127
+ {translateWithFallback(
128
+ t,
129
+ 'customers.deals.kanban.bulk.changeOwner',
130
+ 'Change owner',
131
+ )}
132
+ </span>
133
+ </Button>
134
+ <Button
135
+ variant="ghost"
136
+ size="sm"
137
+ type="button"
138
+ onClick={onExportCsv}
139
+ className="inline-flex items-center gap-1 rounded-md px-3 py-1 text-sm transition-colors hover:bg-background/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-foreground"
140
+ >
141
+ <Download className="size-4" aria-hidden="true" />
142
+ <span>
143
+ {translateWithFallback(
144
+ t,
145
+ 'customers.deals.kanban.bulk.exportCsv',
146
+ 'Export CSV',
147
+ )}
148
+ </span>
149
+ </Button>
150
+ <Button
151
+ variant="destructive-ghost"
152
+ size="sm"
153
+ type="button"
154
+ onClick={onDelete}
155
+ className="inline-flex items-center gap-1 rounded-md px-3 py-1 text-sm text-status-error-text transition-colors hover:bg-status-error-bg/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-foreground"
156
+ >
157
+ <Trash2 className="size-4" aria-hidden="true" />
158
+ <span>
159
+ {translateWithFallback(
160
+ t,
161
+ 'customers.deals.kanban.bulk.delete',
162
+ 'Delete',
163
+ )}
164
+ </span>
165
+ </Button>
166
+ </div>
167
+ </div>
168
+ )
169
+ }
170
+
171
+ export default BulkActionsBar
@@ -0,0 +1,196 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { UserCircle2 } from 'lucide-react'
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '@open-mercato/ui/primitives/dialog'
13
+ import { Button } from '@open-mercato/ui/primitives/button'
14
+ import { Input } from '@open-mercato/ui/primitives/input'
15
+ import { Avatar } from '@open-mercato/ui/primitives/avatar'
16
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
17
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
18
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
19
+ import {
20
+ fetchAssignableStaffMembers,
21
+ type AssignableStaffMember,
22
+ } from '../../../../../components/detail/assignableStaff'
23
+
24
+ type ChangeOwnerDialogProps = {
25
+ open: boolean
26
+ selectedCount: number
27
+ isSubmitting: boolean
28
+ onClose: () => void
29
+ onConfirm: (userId: string | null) => void
30
+ }
31
+
32
+ export function ChangeOwnerDialog({
33
+ open,
34
+ selectedCount,
35
+ isSubmitting,
36
+ onClose,
37
+ onConfirm,
38
+ }: ChangeOwnerDialogProps): React.ReactElement {
39
+ const t = useT()
40
+ const [query, setQuery] = React.useState('')
41
+ const [items, setItems] = React.useState<AssignableStaffMember[]>([])
42
+ const [isLoading, setIsLoading] = React.useState(false)
43
+ const [selectedUserId, setSelectedUserId] = React.useState<string | null>(null)
44
+
45
+ React.useEffect(() => {
46
+ if (!open) return
47
+ setQuery('')
48
+ setSelectedUserId(null)
49
+ }, [open])
50
+
51
+ React.useEffect(() => {
52
+ if (!open) return
53
+ let cancelled = false
54
+ const controller = new AbortController()
55
+ setIsLoading(true)
56
+ fetchAssignableStaffMembers(query, { pageSize: 50, signal: controller.signal })
57
+ .then((next) => {
58
+ if (cancelled) return
59
+ setItems(next)
60
+ if (!selectedUserId && next.length > 0) setSelectedUserId(next[0].userId)
61
+ })
62
+ .catch(() => {
63
+ if (!cancelled) setItems([])
64
+ })
65
+ .finally(() => {
66
+ if (!cancelled) setIsLoading(false)
67
+ })
68
+ return () => {
69
+ cancelled = true
70
+ controller.abort()
71
+ }
72
+ // eslint-disable-next-line react-hooks/exhaustive-deps
73
+ }, [open, query])
74
+
75
+ const handleConfirm = () => {
76
+ onConfirm(selectedUserId)
77
+ }
78
+
79
+ const handleKeyDown = (event: React.KeyboardEvent) => {
80
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
81
+ event.preventDefault()
82
+ handleConfirm()
83
+ }
84
+ }
85
+
86
+ return (
87
+ <Dialog
88
+ open={open}
89
+ onOpenChange={(next) => {
90
+ if (!next && !isSubmitting) onClose()
91
+ }}
92
+ >
93
+ <DialogContent className="sm:max-w-md" onKeyDown={handleKeyDown}>
94
+ <DialogHeader>
95
+ <DialogTitle className="flex items-center gap-2">
96
+ <UserCircle2 className="size-4" aria-hidden="true" />
97
+ {translateWithFallback(
98
+ t,
99
+ 'customers.deals.kanban.bulk.changeOwner.title',
100
+ 'Change owner for {count} deals',
101
+ { count: selectedCount },
102
+ )}
103
+ </DialogTitle>
104
+ <DialogDescription>
105
+ {translateWithFallback(
106
+ t,
107
+ 'customers.deals.kanban.bulk.changeOwner.helper',
108
+ 'Pick a staff member who will own all selected deals.',
109
+ )}
110
+ </DialogDescription>
111
+ </DialogHeader>
112
+
113
+ <div className="flex flex-col gap-3">
114
+ <Input
115
+ value={query}
116
+ onChange={(event) => setQuery(event.target.value)}
117
+ placeholder={translateWithFallback(
118
+ t,
119
+ 'customers.deals.kanban.bulk.changeOwner.search',
120
+ 'Search by name or email…',
121
+ )}
122
+ autoFocus
123
+ />
124
+ <div className="flex max-h-72 flex-col gap-1 overflow-y-auto pr-1">
125
+ {isLoading ? (
126
+ <div className="flex justify-center py-4">
127
+ <Spinner />
128
+ </div>
129
+ ) : items.length === 0 ? (
130
+ <span className="px-3 py-2 text-sm text-muted-foreground">
131
+ {translateWithFallback(
132
+ t,
133
+ 'customers.deals.kanban.bulk.changeOwner.empty',
134
+ 'No staff members found.',
135
+ )}
136
+ </span>
137
+ ) : (
138
+ items.map((member) => {
139
+ const isSelected = selectedUserId === member.userId
140
+ return (
141
+ <Button
142
+ variant="ghost"
143
+ size="default"
144
+ key={member.teamMemberId}
145
+ type="button"
146
+ onClick={() => setSelectedUserId(member.userId)}
147
+ // Overrides on the Button base class:
148
+ // - `flex w-full` → row spans the dialog width (default Button is `inline-flex` and content-sized)
149
+ // - `h-auto` → fit avatar + two stacked text lines (default `h-9` clips and leaves the background only covering the first line — that's the "uneven" symptom)
150
+ // - `justify-start` → left-align contents (default Button is `justify-center`, which pushes the avatar+name toward the middle)
151
+ // - `whitespace-normal` → allow long emails to wrap instead of overflowing the button box (default Button is `whitespace-nowrap`)
152
+ className={`flex h-auto w-full items-center justify-start gap-3 whitespace-normal rounded-md px-3 py-2 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
153
+ isSelected ? 'bg-muted' : ''
154
+ }`}
155
+ >
156
+ <Avatar label={member.displayName} size="sm" />
157
+ <span className="flex min-w-0 flex-1 flex-col items-start">
158
+ <span className="truncate text-sm font-medium text-foreground">
159
+ {member.displayName}
160
+ </span>
161
+ {member.email ? (
162
+ <span className="truncate text-xs text-muted-foreground">
163
+ {member.email}
164
+ </span>
165
+ ) : null}
166
+ </span>
167
+ </Button>
168
+ )
169
+ })
170
+ )}
171
+ </div>
172
+ </div>
173
+
174
+ <DialogFooter>
175
+ <Button variant="outline" onClick={onClose} type="button" disabled={isSubmitting}>
176
+ {translateWithFallback(t, 'customers.deals.kanban.filter.cancel', 'Cancel')}
177
+ </Button>
178
+ <Button
179
+ type="button"
180
+ onClick={handleConfirm}
181
+ disabled={!selectedUserId || isSubmitting}
182
+ >
183
+ {translateWithFallback(
184
+ t,
185
+ 'customers.deals.kanban.bulk.changeOwner.confirm',
186
+ 'Reassign {count} deals',
187
+ { count: selectedCount },
188
+ )}
189
+ </Button>
190
+ </DialogFooter>
191
+ </DialogContent>
192
+ </Dialog>
193
+ )
194
+ }
195
+
196
+ export default ChangeOwnerDialog
@@ -0,0 +1,138 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Workflow } from 'lucide-react'
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '@open-mercato/ui/primitives/dialog'
13
+ import { Button } from '@open-mercato/ui/primitives/button'
14
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
15
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
16
+
17
+ export type ChangeStageOption = {
18
+ id: string
19
+ label: string
20
+ }
21
+
22
+ type ChangeStageDialogProps = {
23
+ open: boolean
24
+ selectedCount: number
25
+ pipelineName: string
26
+ stages: ChangeStageOption[]
27
+ isSubmitting: boolean
28
+ onClose: () => void
29
+ onConfirm: (stageId: string) => void
30
+ }
31
+
32
+ export function ChangeStageDialog({
33
+ open,
34
+ selectedCount,
35
+ pipelineName,
36
+ stages,
37
+ isSubmitting,
38
+ onClose,
39
+ onConfirm,
40
+ }: ChangeStageDialogProps): React.ReactElement {
41
+ const t = useT()
42
+ const [draftStageId, setDraftStageId] = React.useState<string | null>(stages[0]?.id ?? null)
43
+
44
+ React.useEffect(() => {
45
+ if (open) setDraftStageId(stages[0]?.id ?? null)
46
+ }, [open, stages])
47
+
48
+ const handleConfirm = () => {
49
+ if (draftStageId) onConfirm(draftStageId)
50
+ }
51
+
52
+ const handleKeyDown = (event: React.KeyboardEvent) => {
53
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
54
+ event.preventDefault()
55
+ handleConfirm()
56
+ }
57
+ }
58
+
59
+ return (
60
+ <Dialog
61
+ open={open}
62
+ onOpenChange={(next) => {
63
+ if (!next && !isSubmitting) onClose()
64
+ }}
65
+ >
66
+ <DialogContent className="sm:max-w-md" onKeyDown={handleKeyDown}>
67
+ <DialogHeader>
68
+ <DialogTitle className="flex items-center gap-2">
69
+ <Workflow className="size-4" aria-hidden="true" />
70
+ {translateWithFallback(
71
+ t,
72
+ 'customers.deals.kanban.bulk.changeStage.title',
73
+ 'Change stage for {count} deals',
74
+ { count: selectedCount },
75
+ )}
76
+ </DialogTitle>
77
+ <DialogDescription>
78
+ {translateWithFallback(
79
+ t,
80
+ 'customers.deals.kanban.bulk.changeStage.context',
81
+ 'Pipeline: {pipeline}',
82
+ { pipeline: pipelineName },
83
+ )}
84
+ </DialogDescription>
85
+ </DialogHeader>
86
+
87
+ <div className="flex flex-col gap-1 py-1">
88
+ {stages.map((stage) => {
89
+ const isSelected = draftStageId === stage.id
90
+ return (
91
+ <Button
92
+ variant="ghost"
93
+ key={stage.id}
94
+ type="button"
95
+ onClick={() => setDraftStageId(stage.id)}
96
+ className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
97
+ isSelected ? 'bg-muted' : ''
98
+ }`}
99
+ >
100
+ <span className="font-medium text-foreground">{stage.label}</span>
101
+ <span
102
+ className={`inline-flex size-4 items-center justify-center rounded-full border ${
103
+ isSelected ? 'border-primary bg-primary' : 'border-input'
104
+ }`}
105
+ aria-hidden="true"
106
+ >
107
+ {isSelected ? (
108
+ <span className="size-1.5 rounded-full bg-primary-foreground" />
109
+ ) : null}
110
+ </span>
111
+ </Button>
112
+ )
113
+ })}
114
+ </div>
115
+
116
+ <DialogFooter>
117
+ <Button variant="outline" onClick={onClose} type="button" disabled={isSubmitting}>
118
+ {translateWithFallback(t, 'customers.deals.kanban.filter.cancel', 'Cancel')}
119
+ </Button>
120
+ <Button
121
+ type="button"
122
+ onClick={handleConfirm}
123
+ disabled={!draftStageId || isSubmitting || stages.length === 0}
124
+ >
125
+ {translateWithFallback(
126
+ t,
127
+ 'customers.deals.kanban.bulk.changeStage.confirm',
128
+ 'Move {count} deals',
129
+ { count: selectedCount },
130
+ )}
131
+ </Button>
132
+ </DialogFooter>
133
+ </DialogContent>
134
+ </Dialog>
135
+ )
136
+ }
137
+
138
+ export default ChangeStageDialog
@@ -0,0 +1,47 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { ChevronDown } from 'lucide-react'
5
+ import { Button } from '@open-mercato/ui/primitives/button'
6
+
7
+ export type ChipButtonProps = {
8
+ label?: string
9
+ value: string
10
+ active?: boolean
11
+ ariaLabel?: string
12
+ onClick?: () => void
13
+ /** Show the chevron-down affordance — true for popover-backed chips, false for plain chips like "+ More" */
14
+ withChevron?: boolean
15
+ }
16
+
17
+ export const ChipButton = React.forwardRef<HTMLButtonElement, ChipButtonProps>(
18
+ function ChipButton(
19
+ { label, value, active = false, ariaLabel, onClick, withChevron = true },
20
+ ref,
21
+ ) {
22
+ return (
23
+ <Button
24
+ variant="ghost"
25
+ size="sm"
26
+ ref={ref}
27
+ type="button"
28
+ onClick={onClick}
29
+ aria-label={ariaLabel ?? (label ? `${label}: ${value}` : value)}
30
+ aria-pressed={active}
31
+ className={`inline-flex items-center gap-1.5 rounded-sm border border-border bg-card px-2.5 py-1.5 text-xs leading-normal transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
32
+ active ? 'border-foreground/40 bg-muted text-foreground' : 'text-foreground'
33
+ }`}
34
+ >
35
+ {label ? <span className="font-normal text-muted-foreground">{label}:</span> : null}
36
+ <span className={label ? 'font-semibold text-foreground' : 'font-normal text-foreground'}>
37
+ {value}
38
+ </span>
39
+ {withChevron ? (
40
+ <ChevronDown className="size-3 text-muted-foreground" aria-hidden="true" />
41
+ ) : null}
42
+ </Button>
43
+ )
44
+ },
45
+ )
46
+
47
+ export default ChipButton