@open-mercato/core 0.6.3-develop.3635.1.e4a05c16e8 → 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,196 @@
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 PipelineFilterOption = {
16
+ id: string
17
+ name: string
18
+ dealCount?: number
19
+ }
20
+
21
+ type PipelineFilterPopoverProps = {
22
+ pipelines: PipelineFilterOption[]
23
+ selectedPipelineId: string | null
24
+ onApply: (pipelineId: string | null) => void
25
+ }
26
+
27
+ /**
28
+ * Single radio button matching the Figma spec at 1045:11917 — an outlined 16px circle that
29
+ * fills with the accent-indigo when selected. Reused for every row + the "All pipelines"
30
+ * sentinel so the visual is consistent.
31
+ */
32
+ function RadioDot({ selected }: { selected: boolean }): React.ReactElement {
33
+ return (
34
+ <span
35
+ className={`flex size-4 shrink-0 items-center justify-center rounded-full border ${
36
+ selected ? 'border-accent-indigo' : 'border-input bg-card'
37
+ }`}
38
+ aria-hidden="true"
39
+ >
40
+ {selected ? <span className="size-2 rounded-full bg-accent-indigo" /> : null}
41
+ </span>
42
+ )
43
+ }
44
+
45
+ export function PipelineFilterPopover({
46
+ pipelines,
47
+ selectedPipelineId,
48
+ onApply,
49
+ }: PipelineFilterPopoverProps): React.ReactElement {
50
+ const t = useT()
51
+ const [open, setOpen] = React.useState(false)
52
+ const [draft, setDraft] = React.useState<string | null>(selectedPipelineId)
53
+
54
+ React.useEffect(() => {
55
+ if (open) setDraft(selectedPipelineId)
56
+ }, [open, selectedPipelineId])
57
+
58
+ const activePipeline = pipelines.find((pipeline) => pipeline.id === selectedPipelineId)
59
+ const totalCount = pipelines.reduce((sum, pipeline) => sum + (pipeline.dealCount ?? 0), 0)
60
+ const chipLabel = translateWithFallback(t, 'customers.deals.kanban.filter.pipeline', 'Pipeline')
61
+ const chipValue = activePipeline
62
+ ? activePipeline.name
63
+ : translateWithFallback(t, 'customers.deals.kanban.filter.allCount', 'All ({count})', {
64
+ count: pipelines.length,
65
+ })
66
+
67
+ const handleApply = () => {
68
+ onApply(draft)
69
+ setOpen(false)
70
+ }
71
+
72
+ // Cmd/Ctrl+Enter from anywhere inside the popover confirms — parity with the dialog
73
+ // primary-action shortcut (`AGENTS.md` UI Interaction rules).
74
+ const handleKeyDown = (event: React.KeyboardEvent) => {
75
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
76
+ event.preventDefault()
77
+ handleApply()
78
+ }
79
+ }
80
+
81
+ const allSelected = draft === null
82
+ const allRowClass = allSelected ? 'bg-muted' : 'bg-card'
83
+
84
+ const footerLeft = draft ? (
85
+ <span>
86
+ {translateWithFallback(
87
+ t,
88
+ 'customers.deals.kanban.filter.pipeline.selectedOne',
89
+ '1 selected · {name}',
90
+ { name: pipelines.find((pipeline) => pipeline.id === draft)?.name ?? '' },
91
+ )}
92
+ </span>
93
+ ) : (
94
+ <span>
95
+ {translateWithFallback(
96
+ t,
97
+ 'customers.deals.kanban.filter.pipeline.selectedNone',
98
+ 'No pipeline filter',
99
+ )}
100
+ </span>
101
+ )
102
+
103
+ return (
104
+ <Popover open={open} onOpenChange={setOpen}>
105
+ <PopoverTrigger asChild>
106
+ <ChipButton label={chipLabel} value={chipValue} active={!!selectedPipelineId} />
107
+ </PopoverTrigger>
108
+ <PopoverContent
109
+ className="w-96 rounded-2xl border-border bg-transparent p-0 shadow-xl"
110
+ align="start"
111
+ onKeyDown={handleKeyDown}
112
+ >
113
+ <FilterPopoverShell
114
+ title={translateWithFallback(t, 'customers.deals.kanban.filter.pipeline.title', 'Filter · Pipeline')}
115
+ onClose={() => setOpen(false)}
116
+ onCancel={() => setOpen(false)}
117
+ onApply={handleApply}
118
+ footerLeft={footerLeft}
119
+ >
120
+ {/*
121
+ "All pipelines" sentinel row. Per Figma it shows the total deal count in
122
+ accent-indigo (active emphasis), regardless of whether it's selected.
123
+ */}
124
+ <Button
125
+ type="button"
126
+ variant="ghost"
127
+ role="radio"
128
+ aria-checked={allSelected}
129
+ onClick={() => setDraft(null)}
130
+ className={`h-auto w-full justify-start gap-3 px-4 py-2.5 text-left font-normal ${allRowClass}`}
131
+ >
132
+ <RadioDot selected={allSelected} />
133
+ <div className="flex min-w-0 flex-1 flex-col gap-px">
134
+ <span
135
+ className={`text-sm leading-normal text-foreground ${
136
+ allSelected ? 'font-semibold' : 'font-normal'
137
+ }`}
138
+ >
139
+ {translateWithFallback(t, 'customers.deals.kanban.filter.pipeline.all', 'All pipelines')}
140
+ </span>
141
+ <span className="text-xs font-normal leading-normal text-muted-foreground">
142
+ {translateWithFallback(
143
+ t,
144
+ 'customers.deals.kanban.filter.pipeline.allHelper',
145
+ '{count} pipelines',
146
+ { count: pipelines.length },
147
+ )}
148
+ </span>
149
+ </div>
150
+ <span className="text-xs font-semibold leading-normal text-accent-indigo">
151
+ {totalCount}
152
+ </span>
153
+ </Button>
154
+
155
+ {pipelines.map((pipeline) => {
156
+ const isSelected = draft === pipeline.id
157
+ const rowClass = isSelected ? 'bg-muted' : 'bg-card'
158
+ return (
159
+ <Button
160
+ key={pipeline.id}
161
+ type="button"
162
+ variant="ghost"
163
+ role="radio"
164
+ aria-checked={isSelected}
165
+ onClick={() => setDraft(pipeline.id)}
166
+ className={`h-auto w-full justify-start gap-3 px-4 py-2.5 text-left font-normal ${rowClass}`}
167
+ >
168
+ <RadioDot selected={isSelected} />
169
+ <span
170
+ className={`flex-1 truncate text-sm leading-normal text-foreground ${
171
+ isSelected ? 'font-semibold' : 'font-normal'
172
+ }`}
173
+ >
174
+ {pipeline.name}
175
+ </span>
176
+ {typeof pipeline.dealCount === 'number' ? (
177
+ <span
178
+ className={`text-xs leading-normal ${
179
+ isSelected
180
+ ? 'font-semibold text-accent-indigo'
181
+ : 'font-normal text-muted-foreground'
182
+ }`}
183
+ >
184
+ {pipeline.dealCount}
185
+ </span>
186
+ ) : null}
187
+ </Button>
188
+ )
189
+ })}
190
+ </FilterPopoverShell>
191
+ </PopoverContent>
192
+ </Popover>
193
+ )
194
+ }
195
+
196
+ export default PipelineFilterPopover
@@ -0,0 +1,408 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { z } from 'zod'
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from '@open-mercato/ui/primitives/dialog'
12
+ import { Avatar } from '@open-mercato/ui/primitives/avatar'
13
+ import { CrudForm, type CrudField, type CrudFormGroup } from '@open-mercato/ui/backend/CrudForm'
14
+ import { ComboboxInput } from '@open-mercato/ui/backend/inputs/ComboboxInput'
15
+ import { createCrud } from '@open-mercato/ui/backend/utils/crud'
16
+ import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
17
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
18
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
19
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
20
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
21
+
22
+ // Deal status values written by the kanban flow. These intentionally do NOT include 'closed'
23
+ // because the rest of the app only persists 'loose' for lost deals (see StatusFilterPopover
24
+ // notes). 'in_progress' is kept for backwards compatibility with quick-deals created from
25
+ // the previous version of this dialog. The labels are looked up via i18n at render time so
26
+ // the historic typo 'loose' doesn't leak to operators — the storage value stays unchanged.
27
+ const STATUS_OPTIONS = ['open', 'in_progress', 'win', 'loose'] as const
28
+
29
+ /**
30
+ * Tenant currency option. The page resolves these from the currencies module (see
31
+ * `useCurrencyDictionary`) and passes them in; we no longer hardcode the 4-currency
32
+ * list that previously locked tenants on CZK/SEK/HUF/etc. out of quick-add.
33
+ */
34
+ export type QuickDealCurrencyOption = {
35
+ code: string
36
+ label?: string
37
+ isBase?: boolean
38
+ }
39
+
40
+ const FALLBACK_CURRENCIES: QuickDealCurrencyOption[] = [
41
+ { code: 'USD', isBase: true },
42
+ { code: 'EUR' },
43
+ { code: 'GBP' },
44
+ { code: 'PLN' },
45
+ ]
46
+
47
+ export type QuickDealContext = {
48
+ pipelineId: string
49
+ pipelineName: string
50
+ pipelineStageId: string
51
+ pipelineStageLabel: string
52
+ }
53
+
54
+ export type QuickDealCompanyOption = {
55
+ id: string
56
+ label: string
57
+ }
58
+
59
+ type QuickDealDialogProps = {
60
+ open: boolean
61
+ context: QuickDealContext | null
62
+ onClose: () => void
63
+ onCreated: () => void
64
+ currentUserId?: string
65
+ currentUserLabel?: string
66
+ companies?: QuickDealCompanyOption[]
67
+ /**
68
+ * Tenant currency list. When omitted (e.g. dictionary not yet loaded), we render a
69
+ * conservative fallback so the dialog still works end-to-end. The default selection is
70
+ * always the entry with `isBase: true`, falling back to the first item.
71
+ */
72
+ currencies?: QuickDealCurrencyOption[]
73
+ }
74
+
75
+ type QuickDealFormValues = {
76
+ title: string
77
+ valueAmount: number | null
78
+ valueCurrency: string
79
+ companyId: string
80
+ status: (typeof STATUS_OPTIONS)[number]
81
+ probability: number | null
82
+ expectedCloseAt: string
83
+ description: string
84
+ }
85
+
86
+ const QUICK_DEAL_CONTEXT_ID = 'customers-deals-kanban:quick-deal'
87
+
88
+ export function QuickDealDialog({
89
+ open,
90
+ context,
91
+ onClose,
92
+ onCreated,
93
+ currentUserId,
94
+ currentUserLabel,
95
+ companies = [],
96
+ currencies,
97
+ }: QuickDealDialogProps): React.ReactElement | null {
98
+ const t = useT()
99
+ // Re-mount CrudForm whenever the dialog opens so cleared state is consistently fresh.
100
+ // We also key on `context?.pipelineStageId` so reopening from a different stage doesn't
101
+ // reuse stale field values (the page passes a fresh stage in `context` each time).
102
+ const [formInstanceKey, setFormInstanceKey] = React.useState(0)
103
+ React.useEffect(() => {
104
+ if (open) setFormInstanceKey((current) => current + 1)
105
+ }, [open])
106
+
107
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
108
+ formId: string
109
+ resourceKind: string
110
+ retryLastMutation: () => Promise<boolean>
111
+ }>({
112
+ contextId: QUICK_DEAL_CONTEXT_ID,
113
+ blockedMessage: translateWithFallback(t, 'ui.forms.flash.saveBlocked', 'Save blocked by validation'),
114
+ })
115
+
116
+ const titleRequiredMessage = translateWithFallback(
117
+ t,
118
+ 'customers.deals.kanban.quickDeal.title.required',
119
+ 'Title is required.',
120
+ )
121
+
122
+ // Default currency selection: tenant base currency, else first available, else 'USD' as
123
+ // a last-resort literal that satisfies the form validator (which now accepts any 3-char
124
+ // code rather than a hardcoded enum).
125
+ const resolvedCurrencies = React.useMemo(
126
+ () => (currencies && currencies.length > 0 ? currencies : FALLBACK_CURRENCIES),
127
+ [currencies],
128
+ )
129
+ const defaultCurrencyCode = React.useMemo(() => {
130
+ const base = resolvedCurrencies.find((currency) => currency.isBase)
131
+ return base?.code ?? resolvedCurrencies[0]?.code ?? 'USD'
132
+ }, [resolvedCurrencies])
133
+
134
+ const formSchema = React.useMemo(
135
+ () =>
136
+ z.object({
137
+ title: z.string().trim().min(1, titleRequiredMessage),
138
+ valueAmount: z.number().min(0).nullable().optional(),
139
+ // The set of valid currencies is tenant-defined and resolved at runtime; we accept
140
+ // any non-empty string here and let the server (which knows the canonical list)
141
+ // reject anything bogus.
142
+ valueCurrency: z.string().min(1),
143
+ companyId: z.string().optional(),
144
+ status: z.enum(STATUS_OPTIONS),
145
+ probability: z.number().min(0).max(100).nullable().optional(),
146
+ expectedCloseAt: z.string().optional(),
147
+ description: z.string().optional(),
148
+ }),
149
+ // Cast widens to QuickDealFormValues so CrudForm's TValues generic stays exact.
150
+ [titleRequiredMessage],
151
+ ) as unknown as z.ZodType<QuickDealFormValues>
152
+
153
+ const initialValues = React.useMemo<Partial<QuickDealFormValues>>(
154
+ () => ({
155
+ title: '',
156
+ valueAmount: null,
157
+ valueCurrency: defaultCurrencyCode,
158
+ companyId: '',
159
+ status: 'open',
160
+ probability: 25,
161
+ expectedCloseAt: '',
162
+ description: '',
163
+ }),
164
+ [defaultCurrencyCode],
165
+ )
166
+
167
+ const fields = React.useMemo<CrudField[]>(
168
+ () => {
169
+ const list: CrudField[] = [
170
+ {
171
+ // CrudForm auto-focuses the first field of the first group on mount; we don't
172
+ // need (and CrudField doesn't accept) an explicit `autoFocus` prop here.
173
+ id: 'title',
174
+ label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.title.label', 'Deal title'),
175
+ type: 'text',
176
+ required: true,
177
+ placeholder: translateWithFallback(
178
+ t,
179
+ 'customers.deals.kanban.quickDeal.title.placeholder',
180
+ 'e.g. Q3 Expansion — Lighting Package',
181
+ ),
182
+ },
183
+ {
184
+ id: 'valueAmount',
185
+ label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.value', 'Value'),
186
+ type: 'number',
187
+ layout: 'half',
188
+ placeholder: '0',
189
+ },
190
+ {
191
+ id: 'valueCurrency',
192
+ label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.currency', 'Currency'),
193
+ type: 'select',
194
+ layout: 'half',
195
+ options: resolvedCurrencies.map((currency) => ({
196
+ value: currency.code,
197
+ label: currency.label && currency.label !== currency.code
198
+ ? `${currency.code} — ${currency.label}`
199
+ : currency.code,
200
+ })),
201
+ },
202
+ {
203
+ // Company is a typeahead (ComboboxInput) rather than a plain <select>: tenants
204
+ // with hundreds of companies get an unusable dropdown otherwise. The `seedOptions`
205
+ // carry the most-recent companies the page already loaded; an async loader could
206
+ // be added later to hit the companies search endpoint for full coverage.
207
+ id: 'companyId',
208
+ label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.company', 'Company'),
209
+ type: 'custom',
210
+ component: ({ value, setValue }) => (
211
+ <ComboboxInput
212
+ value={typeof value === 'string' ? value : ''}
213
+ onChange={(next) => setValue(next)}
214
+ seedOptions={companies.map((company) => ({ value: company.id, label: company.label }))}
215
+ placeholder={translateWithFallback(
216
+ t,
217
+ 'customers.deals.kanban.quickDeal.companyPh',
218
+ 'Pick a company or add new…',
219
+ )}
220
+ allowCustomValues={false}
221
+ />
222
+ ),
223
+ },
224
+ ]
225
+ // Owner is read-only display of the current user (mirrors original UX). Hidden when
226
+ // we don't know the user — avoids confusing empty avatar cell.
227
+ if (currentUserLabel) {
228
+ list.push({
229
+ id: 'owner',
230
+ label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.owner', 'Owner'),
231
+ type: 'custom',
232
+ readOnly: true,
233
+ component: () => (
234
+ <div className="flex h-9 items-center gap-2 rounded-md border border-input bg-card px-3 text-sm">
235
+ <Avatar label={currentUserLabel} size="sm" />
236
+ <span>{currentUserLabel}</span>
237
+ </div>
238
+ ),
239
+ })
240
+ }
241
+ // Progressive disclosure: status + probability + close date + description live in
242
+ // a separate "More details" group with `defaultExpanded: false` (configured via
243
+ // collapsibleGroups on the host CrudForm).
244
+ list.push(
245
+ {
246
+ id: 'status',
247
+ label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.status', 'Status'),
248
+ type: 'select',
249
+ // Storage value is preserved (`loose` etc.) — only the displayed label is
250
+ // translated, so we never write a localised string back to the DB.
251
+ options: STATUS_OPTIONS.map((statusValue) => ({
252
+ value: statusValue,
253
+ label: translateWithFallback(
254
+ t,
255
+ `customers.deals.kanban.quickDeal.status.${statusValue}`,
256
+ statusValue,
257
+ ),
258
+ })),
259
+ },
260
+ {
261
+ id: 'probability',
262
+ label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.probability', 'Probability'),
263
+ type: 'number',
264
+ layout: 'half',
265
+ placeholder: '25',
266
+ },
267
+ {
268
+ id: 'expectedCloseAt',
269
+ label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.expectedClose', 'Expected close'),
270
+ type: 'date',
271
+ layout: 'half',
272
+ },
273
+ {
274
+ id: 'description',
275
+ label: translateWithFallback(t, 'customers.deals.kanban.quickDeal.description', 'Description'),
276
+ type: 'textarea',
277
+ rows: 3,
278
+ placeholder: translateWithFallback(
279
+ t,
280
+ 'customers.deals.kanban.quickDeal.description.placeholder',
281
+ 'Short context, decision makers or next steps. Markdown supported.',
282
+ ),
283
+ },
284
+ )
285
+ return list
286
+ },
287
+ [companies, currentUserLabel, resolvedCurrencies, t],
288
+ )
289
+
290
+ const groups = React.useMemo<CrudFormGroup[]>(
291
+ () => [
292
+ {
293
+ id: 'basic',
294
+ // No title — keep the basic section flush with the dialog header.
295
+ fields: ['title', 'valueAmount', 'valueCurrency', 'companyId', ...(currentUserLabel ? ['owner'] : [])],
296
+ },
297
+ {
298
+ id: 'more',
299
+ title: translateWithFallback(
300
+ t,
301
+ 'customers.deals.kanban.quickDeal.more',
302
+ 'More details (probability, close date, description)',
303
+ ),
304
+ fields: ['status', 'probability', 'expectedCloseAt', 'description'],
305
+ },
306
+ ],
307
+ [currentUserLabel, t],
308
+ )
309
+
310
+ const handleSubmit = React.useCallback(
311
+ async (values: QuickDealFormValues) => {
312
+ if (!context) return
313
+ const trimmedTitle = values.title.trim()
314
+ if (!trimmedTitle.length) {
315
+ throw createCrudFormError(titleRequiredMessage, { title: titleRequiredMessage })
316
+ }
317
+ const payload: Record<string, unknown> = {
318
+ title: trimmedTitle,
319
+ pipelineId: context.pipelineId,
320
+ pipelineStageId: context.pipelineStageId,
321
+ valueCurrency: values.valueCurrency,
322
+ }
323
+ if (typeof values.valueAmount === 'number' && Number.isFinite(values.valueAmount)) {
324
+ payload.valueAmount = values.valueAmount
325
+ }
326
+ if (currentUserId) payload.ownerUserId = currentUserId
327
+ if (values.companyId) payload.companyIds = [values.companyId]
328
+ if (values.status) payload.status = values.status
329
+ if (typeof values.probability === 'number' && Number.isFinite(values.probability)) {
330
+ payload.probability = values.probability
331
+ }
332
+ if (values.expectedCloseAt && values.expectedCloseAt.trim().length) {
333
+ payload.expectedCloseAt = values.expectedCloseAt
334
+ }
335
+ if (values.description && values.description.trim().length) {
336
+ payload.description = values.description
337
+ }
338
+ const operation = () =>
339
+ createCrud('customers/deals', payload, {
340
+ errorMessage: translateWithFallback(t, 'customers.deals.create.error', 'Failed to create deal.'),
341
+ })
342
+ await runMutation({
343
+ operation,
344
+ context: {
345
+ formId: QUICK_DEAL_CONTEXT_ID,
346
+ resourceKind: 'customers.deal',
347
+ retryLastMutation,
348
+ },
349
+ })
350
+ flash(
351
+ translateWithFallback(
352
+ t,
353
+ 'customers.deals.kanban.quickDeal.success',
354
+ 'Deal added to {stage}.',
355
+ { stage: context.pipelineStageLabel },
356
+ ),
357
+ 'success',
358
+ )
359
+ onCreated()
360
+ onClose()
361
+ },
362
+ [context, currentUserId, onClose, onCreated, retryLastMutation, runMutation, t, titleRequiredMessage],
363
+ )
364
+
365
+ if (!context) return null
366
+
367
+ return (
368
+ <Dialog
369
+ open={open}
370
+ onOpenChange={(next) => {
371
+ if (!next) onClose()
372
+ }}
373
+ >
374
+ <DialogContent>
375
+ <DialogHeader>
376
+ <DialogTitle>
377
+ {translateWithFallback(t, 'customers.deals.kanban.quickDeal.title', 'Quick deal')}
378
+ </DialogTitle>
379
+ <DialogDescription>
380
+ {translateWithFallback(
381
+ t,
382
+ 'customers.deals.kanban.quickDeal.context',
383
+ 'Pipeline: {pipeline} · Stage: {stage}',
384
+ { pipeline: context.pipelineName, stage: context.pipelineStageLabel },
385
+ )}
386
+ </DialogDescription>
387
+ </DialogHeader>
388
+
389
+ <CrudForm<QuickDealFormValues>
390
+ key={`${context.pipelineStageId}:${formInstanceKey}`}
391
+ embedded
392
+ fields={fields}
393
+ groups={groups}
394
+ // The "More details" group starts collapsed; users can expand to set
395
+ // status / probability / close date / description. Mirrors the previous
396
+ // "+ More details" toggle.
397
+ collapsibleGroups={{ pageType: 'customers.deals.kanban.quickDeal' }}
398
+ initialValues={initialValues}
399
+ schema={formSchema}
400
+ submitLabel={translateWithFallback(t, 'customers.deals.kanban.quickDeal.submit', 'Add deal')}
401
+ onSubmit={handleSubmit}
402
+ />
403
+ </DialogContent>
404
+ </Dialog>
405
+ )
406
+ }
407
+
408
+ export default QuickDealDialog