@open-mercato/core 0.4.5-develop-0f0e676c72 → 0.4.5-develop-e694581d9f

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 (113) hide show
  1. package/dist/generated/entities/customer_deal/index.js +4 -0
  2. package/dist/generated/entities/customer_deal/index.js.map +2 -2
  3. package/dist/generated/entities/customer_pipeline/index.js +17 -0
  4. package/dist/generated/entities/customer_pipeline/index.js.map +7 -0
  5. package/dist/generated/entities/customer_pipeline_stage/index.js +19 -0
  6. package/dist/generated/entities/customer_pipeline_stage/index.js.map +7 -0
  7. package/dist/generated/entities.ids.generated.js +2 -0
  8. package/dist/generated/entities.ids.generated.js.map +2 -2
  9. package/dist/generated/entity-fields-registry.js +4 -0
  10. package/dist/generated/entity-fields-registry.js.map +2 -2
  11. package/dist/modules/customers/acl.js +2 -0
  12. package/dist/modules/customers/acl.js.map +2 -2
  13. package/dist/modules/customers/api/deals/[id]/route.js +4 -0
  14. package/dist/modules/customers/api/deals/[id]/route.js.map +2 -2
  15. package/dist/modules/customers/api/deals/route.js +12 -0
  16. package/dist/modules/customers/api/deals/route.js.map +2 -2
  17. package/dist/modules/customers/api/dictionaries/[kind]/route.js +20 -1
  18. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  19. package/dist/modules/customers/api/pipeline-stages/reorder/route.js +69 -0
  20. package/dist/modules/customers/api/pipeline-stages/reorder/route.js.map +7 -0
  21. package/dist/modules/customers/api/pipeline-stages/route.js +275 -0
  22. package/dist/modules/customers/api/pipeline-stages/route.js.map +7 -0
  23. package/dist/modules/customers/api/pipelines/route.js +245 -0
  24. package/dist/modules/customers/api/pipelines/route.js.map +7 -0
  25. package/dist/modules/customers/backend/config/customers/page.js +2 -0
  26. package/dist/modules/customers/backend/config/customers/page.js.map +2 -2
  27. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js +439 -0
  28. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js.map +7 -0
  29. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js +17 -0
  30. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js.map +7 -0
  31. package/dist/modules/customers/backend/customers/deals/[id]/page.js +19 -1
  32. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  33. package/dist/modules/customers/backend/customers/deals/page.js +35 -1
  34. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  35. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +102 -74
  36. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  37. package/dist/modules/customers/cli.js +28 -2
  38. package/dist/modules/customers/cli.js.map +2 -2
  39. package/dist/modules/customers/commands/deals.js +34 -2
  40. package/dist/modules/customers/commands/deals.js.map +2 -2
  41. package/dist/modules/customers/commands/index.js +2 -0
  42. package/dist/modules/customers/commands/index.js.map +2 -2
  43. package/dist/modules/customers/commands/pipeline-stages.js +126 -0
  44. package/dist/modules/customers/commands/pipeline-stages.js.map +7 -0
  45. package/dist/modules/customers/commands/pipelines.js +87 -0
  46. package/dist/modules/customers/commands/pipelines.js.map +7 -0
  47. package/dist/modules/customers/components/DictionarySettings.js +0 -5
  48. package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
  49. package/dist/modules/customers/components/PipelineSettings.js +474 -0
  50. package/dist/modules/customers/components/PipelineSettings.js.map +7 -0
  51. package/dist/modules/customers/components/detail/DealForm.js +84 -12
  52. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  53. package/dist/modules/customers/data/entities.js +78 -0
  54. package/dist/modules/customers/data/entities.js.map +2 -2
  55. package/dist/modules/customers/data/validators.js +44 -0
  56. package/dist/modules/customers/data/validators.js.map +2 -2
  57. package/dist/modules/customers/migrations/Migration20260218191730.js +77 -0
  58. package/dist/modules/customers/migrations/Migration20260218191730.js.map +7 -0
  59. package/dist/modules/customers/setup.js +7 -3
  60. package/dist/modules/customers/setup.js.map +2 -2
  61. package/dist/modules/translations/api/[entityType]/[entityId]/route.js +46 -44
  62. package/dist/modules/translations/api/[entityType]/[entityId]/route.js.map +2 -2
  63. package/dist/modules/translations/api/context.js +10 -1
  64. package/dist/modules/translations/api/context.js.map +2 -2
  65. package/dist/modules/translations/commands/index.js +2 -0
  66. package/dist/modules/translations/commands/index.js.map +7 -0
  67. package/dist/modules/translations/commands/translations.js +160 -0
  68. package/dist/modules/translations/commands/translations.js.map +7 -0
  69. package/dist/modules/translations/index.js +1 -0
  70. package/dist/modules/translations/index.js.map +2 -2
  71. package/dist/modules/workflows/migrations/Migration20260222205305.js +14 -0
  72. package/dist/modules/workflows/migrations/Migration20260222205305.js.map +7 -0
  73. package/generated/entities/customer_deal/index.ts +2 -0
  74. package/generated/entities/customer_pipeline/index.ts +7 -0
  75. package/generated/entities/customer_pipeline_stage/index.ts +8 -0
  76. package/generated/entities.ids.generated.ts +2 -0
  77. package/generated/entity-fields-registry.ts +4 -0
  78. package/package.json +2 -2
  79. package/src/modules/customers/acl.ts +2 -0
  80. package/src/modules/customers/api/deals/[id]/route.ts +4 -0
  81. package/src/modules/customers/api/deals/route.ts +12 -0
  82. package/src/modules/customers/api/dictionaries/[kind]/route.ts +21 -1
  83. package/src/modules/customers/api/pipeline-stages/reorder/route.ts +71 -0
  84. package/src/modules/customers/api/pipeline-stages/route.ts +296 -0
  85. package/src/modules/customers/api/pipelines/route.ts +261 -0
  86. package/src/modules/customers/backend/config/customers/page.tsx +2 -0
  87. package/src/modules/customers/backend/config/customers/pipeline-stages/page.meta.ts +13 -0
  88. package/src/modules/customers/backend/config/customers/pipeline-stages/page.tsx +512 -0
  89. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +21 -1
  90. package/src/modules/customers/backend/customers/deals/page.tsx +33 -1
  91. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +119 -79
  92. package/src/modules/customers/cli.ts +29 -1
  93. package/src/modules/customers/commands/deals.ts +44 -1
  94. package/src/modules/customers/commands/index.ts +2 -0
  95. package/src/modules/customers/commands/pipeline-stages.ts +156 -0
  96. package/src/modules/customers/commands/pipelines.ts +105 -0
  97. package/src/modules/customers/components/DictionarySettings.tsx +0 -5
  98. package/src/modules/customers/components/PipelineSettings.tsx +570 -0
  99. package/src/modules/customers/components/detail/DealForm.tsx +89 -11
  100. package/src/modules/customers/data/entities.ts +64 -0
  101. package/src/modules/customers/data/validators.ts +57 -0
  102. package/src/modules/customers/i18n/de.json +4 -0
  103. package/src/modules/customers/i18n/en.json +4 -0
  104. package/src/modules/customers/i18n/es.json +4 -0
  105. package/src/modules/customers/i18n/pl.json +5 -1
  106. package/src/modules/customers/migrations/Migration20260218191730.ts +84 -0
  107. package/src/modules/customers/setup.ts +5 -1
  108. package/src/modules/translations/api/[entityType]/[entityId]/route.ts +65 -60
  109. package/src/modules/translations/api/context.ts +12 -0
  110. package/src/modules/translations/commands/index.ts +1 -0
  111. package/src/modules/translations/commands/translations.ts +253 -0
  112. package/src/modules/translations/index.ts +1 -0
  113. package/src/modules/workflows/migrations/Migration20260222205305.ts +13 -0
@@ -0,0 +1,570 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Button } from '@open-mercato/ui/primitives/button'
5
+ import { Input } from '@open-mercato/ui/primitives/input'
6
+ import { Label } from '@open-mercato/ui/primitives/label'
7
+ import { Checkbox } from '@open-mercato/ui/primitives/checkbox'
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogFooter,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from '@open-mercato/ui/primitives/dialog'
15
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
16
+ import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
17
+ import { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'
18
+ import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
19
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
20
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
21
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
22
+ import {
23
+ AppearanceSelector,
24
+ type AppearanceSelectorLabels,
25
+ } from '@open-mercato/core/modules/dictionaries/components/AppearanceSelector'
26
+ import {
27
+ renderDictionaryColor,
28
+ renderDictionaryIcon,
29
+ } from '@open-mercato/core/modules/dictionaries/components/dictionaryAppearance'
30
+
31
+ type Pipeline = {
32
+ id: string
33
+ name: string
34
+ isDefault: boolean
35
+ organizationId: string
36
+ tenantId: string
37
+ }
38
+
39
+ type PipelineStage = {
40
+ id: string
41
+ pipelineId: string
42
+ label: string
43
+ order: number
44
+ color: string | null
45
+ icon: string | null
46
+ }
47
+
48
+ type PipelineDialogState =
49
+ | { mode: 'create' }
50
+ | { mode: 'edit'; entry: Pipeline }
51
+
52
+ type StageDialogState =
53
+ | { mode: 'create'; pipelineId: string }
54
+ | { mode: 'edit'; entry: PipelineStage }
55
+
56
+ function normalizePipeline(raw: Record<string, unknown>): Pipeline {
57
+ return {
58
+ id: typeof raw.id === 'string' ? raw.id : '',
59
+ name: typeof raw.name === 'string' ? raw.name : '',
60
+ isDefault: raw.isDefault === true || raw.is_default === true,
61
+ organizationId: typeof raw.organizationId === 'string' ? raw.organizationId : (typeof raw.organization_id === 'string' ? raw.organization_id : ''),
62
+ tenantId: typeof raw.tenantId === 'string' ? raw.tenantId : (typeof raw.tenant_id === 'string' ? raw.tenant_id : ''),
63
+ }
64
+ }
65
+
66
+ function normalizeStage(raw: Record<string, unknown>): PipelineStage {
67
+ return {
68
+ id: typeof raw.id === 'string' ? raw.id : '',
69
+ pipelineId: typeof raw.pipelineId === 'string' ? raw.pipelineId : (typeof raw.pipeline_id === 'string' ? raw.pipeline_id : ''),
70
+ label: typeof raw.label === 'string' ? raw.label : '',
71
+ order: typeof raw.order === 'number' ? raw.order : 0,
72
+ color: typeof raw.color === 'string' && raw.color.trim().length ? raw.color.trim() : null,
73
+ icon: typeof raw.icon === 'string' && raw.icon.trim().length ? raw.icon.trim() : null,
74
+ }
75
+ }
76
+
77
+ export default function PipelineSettings(): React.ReactElement {
78
+ const t = useT()
79
+ const scopeVersion = useOrganizationScopeVersion()
80
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
81
+
82
+ const [pipelines, setPipelines] = React.useState<Pipeline[]>([])
83
+ const [loadingPipelines, setLoadingPipelines] = React.useState(false)
84
+ const [pipelineDialog, setPipelineDialog] = React.useState<PipelineDialogState | null>(null)
85
+ const [pipelineForm, setPipelineForm] = React.useState({ name: '', isDefault: false })
86
+ const [submittingPipeline, setSubmittingPipeline] = React.useState(false)
87
+
88
+ const [expandedPipelineId, setExpandedPipelineId] = React.useState<string | null>(null)
89
+ const [stages, setStages] = React.useState<Record<string, PipelineStage[]>>({})
90
+ const [loadingStages, setLoadingStages] = React.useState<Record<string, boolean>>({})
91
+ const [stageDialog, setStageDialog] = React.useState<StageDialogState | null>(null)
92
+ const [stageForm, setStageForm] = React.useState({ label: '', color: null as string | null, icon: null as string | null })
93
+ const [submittingStage, setSubmittingStage] = React.useState(false)
94
+
95
+ const loadPipelines = React.useCallback(async () => {
96
+ setLoadingPipelines(true)
97
+ try {
98
+ const data = await readApiResultOrThrow<{ items?: unknown[] }>(
99
+ '/api/customers/pipelines',
100
+ undefined,
101
+ { errorMessage: t('customers.pipelines.errors.loadFailed', 'Failed to load pipelines'), fallback: { items: [] } },
102
+ )
103
+ const items = Array.isArray(data?.items) ? data.items : []
104
+ setPipelines(items.map((item) => normalizePipeline(item as Record<string, unknown>)))
105
+ } finally {
106
+ setLoadingPipelines(false)
107
+ }
108
+ }, [t])
109
+
110
+ const loadStages = React.useCallback(async (pipelineId: string) => {
111
+ setLoadingStages((prev) => ({ ...prev, [pipelineId]: true }))
112
+ try {
113
+ const data = await readApiResultOrThrow<{ items?: unknown[] }>(
114
+ `/api/customers/pipeline-stages?pipelineId=${encodeURIComponent(pipelineId)}`,
115
+ undefined,
116
+ { errorMessage: t('customers.pipelines.errors.stagesLoadFailed', 'Failed to load stages'), fallback: { items: [] } },
117
+ )
118
+ const items = Array.isArray(data?.items) ? data.items : []
119
+ setStages((prev) => ({
120
+ ...prev,
121
+ [pipelineId]: items.map((item) => normalizeStage(item as Record<string, unknown>)),
122
+ }))
123
+ } finally {
124
+ setLoadingStages((prev) => ({ ...prev, [pipelineId]: false }))
125
+ }
126
+ }, [t])
127
+
128
+ React.useEffect(() => {
129
+ void loadPipelines()
130
+ }, [loadPipelines, scopeVersion])
131
+
132
+ React.useEffect(() => {
133
+ if (expandedPipelineId) {
134
+ void loadStages(expandedPipelineId)
135
+ }
136
+ }, [expandedPipelineId, loadStages])
137
+
138
+ const openCreatePipeline = React.useCallback(() => {
139
+ setPipelineForm({ name: '', isDefault: false })
140
+ setPipelineDialog({ mode: 'create' })
141
+ }, [])
142
+
143
+ const openEditPipeline = React.useCallback((pipeline: Pipeline) => {
144
+ setPipelineForm({ name: pipeline.name, isDefault: pipeline.isDefault })
145
+ setPipelineDialog({ mode: 'edit', entry: pipeline })
146
+ }, [])
147
+
148
+ const closePipelineDialog = React.useCallback(() => {
149
+ setPipelineDialog(null)
150
+ }, [])
151
+
152
+ const handlePipelineSubmit = React.useCallback(async () => {
153
+ if (!pipelineForm.name.trim()) return
154
+ setSubmittingPipeline(true)
155
+ try {
156
+ if (pipelineDialog?.mode === 'create') {
157
+ const res = await apiCall('/api/customers/pipelines', {
158
+ method: 'POST',
159
+ headers: { 'content-type': 'application/json' },
160
+ body: JSON.stringify({ name: pipelineForm.name.trim(), isDefault: pipelineForm.isDefault }),
161
+ })
162
+ if (!res.ok) {
163
+ await raiseCrudError(res.response, t('customers.pipelines.errors.createFailed', 'Failed to create pipeline'))
164
+ return
165
+ }
166
+ flash(t('customers.pipelines.flash.created', 'Pipeline created'), 'success')
167
+ } else if (pipelineDialog?.mode === 'edit') {
168
+ const res = await apiCall('/api/customers/pipelines', {
169
+ method: 'PUT',
170
+ headers: { 'content-type': 'application/json' },
171
+ body: JSON.stringify({ id: pipelineDialog.entry.id, name: pipelineForm.name.trim(), isDefault: pipelineForm.isDefault }),
172
+ })
173
+ if (!res.ok) {
174
+ await raiseCrudError(res.response, t('customers.pipelines.errors.updateFailed', 'Failed to update pipeline'))
175
+ return
176
+ }
177
+ flash(t('customers.pipelines.flash.updated', 'Pipeline updated'), 'success')
178
+ }
179
+ setPipelineDialog(null)
180
+ await loadPipelines()
181
+ } finally {
182
+ setSubmittingPipeline(false)
183
+ }
184
+ }, [pipelineDialog, pipelineForm, loadPipelines, t])
185
+
186
+ const handleDeletePipeline = React.useCallback(async (pipeline: Pipeline) => {
187
+ const confirmed = await confirm({
188
+ title: t('customers.pipelines.confirm.deleteTitle', 'Delete pipeline'),
189
+ text: t('customers.pipelines.confirm.deleteDesc', 'Are you sure you want to delete this pipeline? This cannot be undone.'),
190
+ confirmText: t('customers.pipelines.confirm.deleteConfirm', 'Delete'),
191
+ variant: 'destructive',
192
+ })
193
+ if (!confirmed) return
194
+ const res = await apiCall('/api/customers/pipelines', {
195
+ method: 'DELETE',
196
+ headers: { 'content-type': 'application/json' },
197
+ body: JSON.stringify({ id: pipeline.id }),
198
+ })
199
+ if (!res.ok) {
200
+ const body = (res.result ?? {}) as Record<string, unknown>
201
+ const msg = typeof body.error === 'string' ? body.error : t('customers.pipelines.errors.deleteFailed', 'Failed to delete pipeline')
202
+ flash(msg, 'error')
203
+ return
204
+ }
205
+ flash(t('customers.pipelines.flash.deleted', 'Pipeline deleted'), 'success')
206
+ if (expandedPipelineId === pipeline.id) setExpandedPipelineId(null)
207
+ await loadPipelines()
208
+ }, [confirm, expandedPipelineId, loadPipelines, t])
209
+
210
+ const toggleExpand = React.useCallback((pipelineId: string) => {
211
+ setExpandedPipelineId((prev) => (prev === pipelineId ? null : pipelineId))
212
+ }, [])
213
+
214
+ const openCreateStage = React.useCallback((pipelineId: string) => {
215
+ setStageForm({ label: '', color: null, icon: null })
216
+ setStageDialog({ mode: 'create', pipelineId })
217
+ }, [])
218
+
219
+ const openEditStage = React.useCallback((stage: PipelineStage) => {
220
+ setStageForm({ label: stage.label, color: stage.color, icon: stage.icon })
221
+ setStageDialog({ mode: 'edit', entry: stage })
222
+ }, [])
223
+
224
+ const closeStageDialog = React.useCallback(() => {
225
+ setStageDialog(null)
226
+ }, [])
227
+
228
+ const handleStageSubmit = React.useCallback(async () => {
229
+ if (!stageForm.label.trim()) return
230
+ setSubmittingStage(true)
231
+ try {
232
+ const appearance: Record<string, unknown> = {}
233
+ if (stageForm.color) appearance.color = stageForm.color
234
+ if (stageForm.icon) appearance.icon = stageForm.icon
235
+
236
+ if (stageDialog?.mode === 'create') {
237
+ const res = await apiCall('/api/customers/pipeline-stages', {
238
+ method: 'POST',
239
+ headers: { 'content-type': 'application/json' },
240
+ body: JSON.stringify({ pipelineId: stageDialog.pipelineId, label: stageForm.label.trim(), ...appearance }),
241
+ })
242
+ if (!res.ok) {
243
+ await raiseCrudError(res.response, t('customers.pipelines.errors.stageCreateFailed', 'Failed to create stage'))
244
+ return
245
+ }
246
+ flash(t('customers.pipelines.flash.stageCreated', 'Stage created'), 'success')
247
+ await loadStages(stageDialog.pipelineId)
248
+ } else if (stageDialog?.mode === 'edit') {
249
+ const res = await apiCall('/api/customers/pipeline-stages', {
250
+ method: 'PUT',
251
+ headers: { 'content-type': 'application/json' },
252
+ body: JSON.stringify({ id: stageDialog.entry.id, label: stageForm.label.trim(), ...appearance }),
253
+ })
254
+ if (!res.ok) {
255
+ await raiseCrudError(res.response, t('customers.pipelines.errors.stageUpdateFailed', 'Failed to update stage'))
256
+ return
257
+ }
258
+ flash(t('customers.pipelines.flash.stageUpdated', 'Stage updated'), 'success')
259
+ await loadStages(stageDialog.entry.pipelineId)
260
+ }
261
+ setStageDialog(null)
262
+ } finally {
263
+ setSubmittingStage(false)
264
+ }
265
+ }, [stageDialog, stageForm, loadStages, t])
266
+
267
+ const handleDeleteStage = React.useCallback(async (stage: PipelineStage) => {
268
+ const confirmed = await confirm({
269
+ title: t('customers.pipelines.confirm.stageDeleteTitle', 'Delete stage'),
270
+ text: t('customers.pipelines.confirm.stageDeleteDesc', 'Are you sure you want to delete this stage?'),
271
+ confirmText: t('customers.pipelines.confirm.stageDeleteConfirm', 'Delete'),
272
+ variant: 'destructive',
273
+ })
274
+ if (!confirmed) return
275
+ const res = await apiCall('/api/customers/pipeline-stages', {
276
+ method: 'DELETE',
277
+ headers: { 'content-type': 'application/json' },
278
+ body: JSON.stringify({ id: stage.id }),
279
+ })
280
+ if (!res.ok) {
281
+ const body = (res.result ?? {}) as Record<string, unknown>
282
+ const msg = typeof body.error === 'string' ? body.error : t('customers.pipelines.errors.stageDeleteFailed', 'Failed to delete stage')
283
+ flash(msg, 'error')
284
+ return
285
+ }
286
+ flash(t('customers.pipelines.flash.stageDeleted', 'Stage deleted'), 'success')
287
+ await loadStages(stage.pipelineId)
288
+ }, [confirm, loadStages, t])
289
+
290
+ const handleMoveStage = React.useCallback(async (stage: PipelineStage, direction: 'up' | 'down') => {
291
+ const pipelineStages = stages[stage.pipelineId] ?? []
292
+ const idx = pipelineStages.findIndex((s) => s.id === stage.id)
293
+ if (idx < 0) return
294
+ const swapIdx = direction === 'up' ? idx - 1 : idx + 1
295
+ if (swapIdx < 0 || swapIdx >= pipelineStages.length) return
296
+
297
+ const reordered = [...pipelineStages]
298
+ const temp = reordered[idx]
299
+ reordered[idx] = reordered[swapIdx]
300
+ reordered[swapIdx] = temp
301
+
302
+ const orderedStages = reordered.map((s, i) => ({ id: s.id, order: i }))
303
+ const res = await apiCall('/api/customers/pipeline-stages/reorder', {
304
+ method: 'POST',
305
+ headers: { 'content-type': 'application/json' },
306
+ body: JSON.stringify({ stages: orderedStages }),
307
+ })
308
+ if (!res.ok) {
309
+ flash(t('customers.pipelines.errors.reorderFailed', 'Failed to reorder stages'), 'error')
310
+ return
311
+ }
312
+ await loadStages(stage.pipelineId)
313
+ }, [stages, loadStages, t])
314
+
315
+ const handleKeyDown = React.useCallback(
316
+ (handler: () => void) => (e: React.KeyboardEvent) => {
317
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
318
+ e.preventDefault()
319
+ handler()
320
+ }
321
+ },
322
+ [],
323
+ )
324
+
325
+ const appearanceLabels = React.useMemo<AppearanceSelectorLabels>(() => ({
326
+ colorLabel: t('customers.pipelines.stageForm.color', 'Color'),
327
+ colorClearLabel: t('customers.pipelines.stageForm.colorClear', 'Remove color'),
328
+ iconLabel: t('customers.pipelines.stageForm.icon', 'Icon'),
329
+ iconPlaceholder: t('customers.pipelines.stageForm.iconPlaceholder', 'e.g. lucide:star'),
330
+ iconPickerTriggerLabel: t('customers.pipelines.stageForm.iconPicker', 'Pick icon'),
331
+ iconSearchPlaceholder: t('customers.pipelines.stageForm.iconSearch', 'Search icons…'),
332
+ iconSearchEmptyLabel: t('customers.pipelines.stageForm.iconSearchEmpty', 'No icons found'),
333
+ iconSuggestionsLabel: t('customers.pipelines.stageForm.iconSuggestions', 'Suggestions'),
334
+ iconClearLabel: t('customers.pipelines.stageForm.iconClear', 'Remove icon'),
335
+ previewEmptyLabel: t('customers.pipelines.stageForm.previewEmpty', 'No appearance set'),
336
+ }), [t])
337
+
338
+ return (
339
+ <div className="space-y-4">
340
+ <div className="flex items-center justify-between">
341
+ <div>
342
+ <h3 className="text-base font-semibold">{t('customers.pipelines.title', 'Sales Pipelines')}</h3>
343
+ <p className="text-sm text-muted-foreground">
344
+ {t('customers.pipelines.description', 'Manage sales pipelines and their stages.')}
345
+ </p>
346
+ </div>
347
+ <Button size="sm" onClick={openCreatePipeline}>
348
+ {t('customers.pipelines.actions.create', 'Add pipeline')}
349
+ </Button>
350
+ </div>
351
+
352
+ {loadingPipelines ? (
353
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
354
+ <Spinner className="h-4 w-4" />
355
+ {t('customers.pipelines.loading', 'Loading pipelines…')}
356
+ </div>
357
+ ) : pipelines.length === 0 ? (
358
+ <p className="text-sm text-muted-foreground">
359
+ {t('customers.pipelines.empty', 'No pipelines yet. Create one to get started.')}
360
+ </p>
361
+ ) : (
362
+ <div className="divide-y divide-border rounded-md border">
363
+ {pipelines.map((pipeline) => {
364
+ const isExpanded = expandedPipelineId === pipeline.id
365
+ const pipelineStages = stages[pipeline.id] ?? []
366
+ const isLoadingStages = loadingStages[pipeline.id] ?? false
367
+
368
+ return (
369
+ <div key={pipeline.id}>
370
+ <div className="flex items-center justify-between gap-3 px-4 py-3">
371
+ <div className="flex items-center gap-2">
372
+ <span className="text-sm font-medium">{pipeline.name}</span>
373
+ {pipeline.isDefault ? (
374
+ <span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
375
+ {t('customers.pipelines.defaultBadge', 'Default')}
376
+ </span>
377
+ ) : null}
378
+ </div>
379
+ <div className="flex items-center gap-2">
380
+ <Button
381
+ variant="ghost"
382
+ size="sm"
383
+ onClick={() => toggleExpand(pipeline.id)}
384
+ >
385
+ {isExpanded
386
+ ? t('customers.pipelines.actions.hideStages', 'Hide stages')
387
+ : t('customers.pipelines.actions.manageStages', 'Manage stages')}
388
+ </Button>
389
+ <Button variant="ghost" size="sm" onClick={() => openEditPipeline(pipeline)}>
390
+ {t('customers.pipelines.actions.edit', 'Edit')}
391
+ </Button>
392
+ <Button
393
+ variant="ghost"
394
+ size="sm"
395
+ className="text-destructive hover:text-destructive"
396
+ onClick={() => void handleDeletePipeline(pipeline)}
397
+ >
398
+ {t('customers.pipelines.actions.delete', 'Delete')}
399
+ </Button>
400
+ </div>
401
+ </div>
402
+
403
+ {isExpanded ? (
404
+ <div className="border-t border-border bg-muted/30 px-4 py-3">
405
+ <div className="mb-3 flex items-center justify-between">
406
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
407
+ {t('customers.pipelines.stages.title', 'Stages')}
408
+ </span>
409
+ <Button size="sm" variant="outline" onClick={() => openCreateStage(pipeline.id)}>
410
+ {t('customers.pipelines.stages.add', 'Add stage')}
411
+ </Button>
412
+ </div>
413
+
414
+ {isLoadingStages ? (
415
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
416
+ <Spinner className="h-3 w-3" />
417
+ {t('customers.pipelines.stages.loading', 'Loading…')}
418
+ </div>
419
+ ) : pipelineStages.length === 0 ? (
420
+ <p className="text-sm text-muted-foreground">
421
+ {t('customers.pipelines.stages.empty', 'No stages yet.')}
422
+ </p>
423
+ ) : (
424
+ <div className="divide-y divide-border rounded-md border bg-background">
425
+ {pipelineStages.map((stage, idx) => (
426
+ <div key={stage.id} className="flex items-center justify-between gap-3 px-3 py-2">
427
+ <div className="flex items-center gap-2">
428
+ <span className="w-5 text-center text-xs text-muted-foreground">{idx + 1}</span>
429
+ {stage.color ? renderDictionaryColor(stage.color, 'h-3 w-3 rounded-full') : null}
430
+ {stage.icon ? renderDictionaryIcon(stage.icon, 'h-4 w-4') : null}
431
+ <span className="text-sm">{stage.label}</span>
432
+ </div>
433
+ <div className="flex items-center gap-1">
434
+ <Button
435
+ variant="ghost"
436
+ size="icon"
437
+ className="h-7 w-7"
438
+ disabled={idx === 0}
439
+ onClick={() => void handleMoveStage(stage, 'up')}
440
+ title={t('customers.pipelines.stages.moveUp', 'Move up')}
441
+ >
442
+
443
+ </Button>
444
+ <Button
445
+ variant="ghost"
446
+ size="icon"
447
+ className="h-7 w-7"
448
+ disabled={idx === pipelineStages.length - 1}
449
+ onClick={() => void handleMoveStage(stage, 'down')}
450
+ title={t('customers.pipelines.stages.moveDown', 'Move down')}
451
+ >
452
+
453
+ </Button>
454
+ <Button
455
+ variant="ghost"
456
+ size="sm"
457
+ onClick={() => openEditStage(stage)}
458
+ >
459
+ {t('customers.pipelines.stages.edit', 'Edit')}
460
+ </Button>
461
+ <Button
462
+ variant="ghost"
463
+ size="sm"
464
+ className="text-destructive hover:text-destructive"
465
+ onClick={() => void handleDeleteStage(stage)}
466
+ >
467
+ {t('customers.pipelines.stages.delete', 'Delete')}
468
+ </Button>
469
+ </div>
470
+ </div>
471
+ ))}
472
+ </div>
473
+ )}
474
+ </div>
475
+ ) : null}
476
+ </div>
477
+ )
478
+ })}
479
+ </div>
480
+ )}
481
+
482
+ {/* Pipeline Dialog */}
483
+ <Dialog open={pipelineDialog !== null} onOpenChange={(open) => { if (!open) closePipelineDialog() }}>
484
+ <DialogContent onKeyDown={handleKeyDown(handlePipelineSubmit)}>
485
+ <DialogHeader>
486
+ <DialogTitle>
487
+ {pipelineDialog?.mode === 'create'
488
+ ? t('customers.pipelines.dialog.createTitle', 'Create pipeline')
489
+ : t('customers.pipelines.dialog.editTitle', 'Edit pipeline')}
490
+ </DialogTitle>
491
+ </DialogHeader>
492
+ <div className="space-y-4 py-2">
493
+ <div className="space-y-2">
494
+ <Label htmlFor="pipeline-name">{t('customers.pipelines.form.name', 'Name')}</Label>
495
+ <Input
496
+ id="pipeline-name"
497
+ value={pipelineForm.name}
498
+ onChange={(e) => setPipelineForm((prev) => ({ ...prev, name: e.target.value }))}
499
+ placeholder={t('customers.pipelines.form.namePlaceholder', 'e.g. New Business')}
500
+ autoFocus
501
+ />
502
+ </div>
503
+ <div className="flex items-center gap-2">
504
+ <Checkbox
505
+ id="pipeline-default"
506
+ checked={pipelineForm.isDefault}
507
+ onCheckedChange={(checked) => setPipelineForm((prev) => ({ ...prev, isDefault: checked === true }))}
508
+ />
509
+ <Label htmlFor="pipeline-default" className="cursor-pointer">
510
+ {t('customers.pipelines.form.isDefault', 'Set as default pipeline')}
511
+ </Label>
512
+ </div>
513
+ </div>
514
+ <DialogFooter>
515
+ <Button variant="outline" onClick={closePipelineDialog} disabled={submittingPipeline}>
516
+ {t('customers.pipelines.dialog.cancel', 'Cancel')}
517
+ </Button>
518
+ <Button onClick={() => void handlePipelineSubmit()} disabled={submittingPipeline || !pipelineForm.name.trim()}>
519
+ {submittingPipeline ? <Spinner className="mr-2 h-4 w-4" /> : null}
520
+ {t('customers.pipelines.dialog.save', 'Save')}
521
+ </Button>
522
+ </DialogFooter>
523
+ </DialogContent>
524
+ </Dialog>
525
+
526
+ {ConfirmDialogElement}
527
+
528
+ {/* Stage Dialog */}
529
+ <Dialog open={stageDialog !== null} onOpenChange={(open) => { if (!open) closeStageDialog() }}>
530
+ <DialogContent onKeyDown={handleKeyDown(handleStageSubmit)}>
531
+ <DialogHeader>
532
+ <DialogTitle>
533
+ {stageDialog?.mode === 'create'
534
+ ? t('customers.pipelines.stageDialog.createTitle', 'Add stage')
535
+ : t('customers.pipelines.stageDialog.editTitle', 'Edit stage')}
536
+ </DialogTitle>
537
+ </DialogHeader>
538
+ <div className="space-y-4 py-2">
539
+ <div className="space-y-2">
540
+ <Label htmlFor="stage-label">{t('customers.pipelines.stageForm.label', 'Label')}</Label>
541
+ <Input
542
+ id="stage-label"
543
+ value={stageForm.label}
544
+ onChange={(e) => setStageForm((prev) => ({ ...prev, label: e.target.value }))}
545
+ placeholder={t('customers.pipelines.stageForm.labelPlaceholder', 'e.g. Discovery')}
546
+ autoFocus
547
+ />
548
+ </div>
549
+ <AppearanceSelector
550
+ color={stageForm.color}
551
+ icon={stageForm.icon}
552
+ onColorChange={(next) => setStageForm((prev) => ({ ...prev, color: next }))}
553
+ onIconChange={(next) => setStageForm((prev) => ({ ...prev, icon: next }))}
554
+ labels={appearanceLabels}
555
+ />
556
+ </div>
557
+ <DialogFooter>
558
+ <Button variant="outline" onClick={closeStageDialog} disabled={submittingStage}>
559
+ {t('customers.pipelines.stageDialog.cancel', 'Cancel')}
560
+ </Button>
561
+ <Button onClick={() => void handleStageSubmit()} disabled={submittingStage || !stageForm.label.trim()}>
562
+ {submittingStage ? <Spinner className="mr-2 h-4 w-4" /> : null}
563
+ {t('customers.pipelines.stageDialog.save', 'Save')}
564
+ </Button>
565
+ </DialogFooter>
566
+ </DialogContent>
567
+ </Dialog>
568
+ </div>
569
+ )
570
+ }