@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,512 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
7
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
8
+ import { Button } from '@open-mercato/ui/primitives/button'
9
+ import { Input } from '@open-mercato/ui/primitives/input'
10
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@open-mercato/ui/primitives/dialog'
11
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
12
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
13
+ import { AppearanceSelector, type AppearanceSelectorLabels } from '@open-mercato/core/modules/dictionaries/components/AppearanceSelector'
14
+ import { renderDictionaryColor, renderDictionaryIcon, ICON_SUGGESTIONS } from '@open-mercato/core/modules/dictionaries/components/dictionaryAppearance'
15
+
16
+ type Pipeline = {
17
+ id: string
18
+ name: string
19
+ isDefault: boolean
20
+ }
21
+
22
+ type PipelineStage = {
23
+ id: string
24
+ pipelineId: string
25
+ label: string
26
+ order: number
27
+ color: string | null
28
+ icon: string | null
29
+ }
30
+
31
+ type PipelineDialogState =
32
+ | { mode: 'create' }
33
+ | { mode: 'edit'; pipeline: Pipeline }
34
+ | null
35
+
36
+ type StageDialogState =
37
+ | { mode: 'create' }
38
+ | { mode: 'edit'; stage: PipelineStage }
39
+ | null
40
+
41
+ export default function PipelineStagesPage() {
42
+ const t = useT()
43
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
44
+
45
+ const [pipelines, setPipelines] = React.useState<Pipeline[]>([])
46
+ const [selectedPipelineId, setSelectedPipelineId] = React.useState<string | null>(null)
47
+ const [stages, setStages] = React.useState<PipelineStage[]>([])
48
+ const [loadingPipelines, setLoadingPipelines] = React.useState(true)
49
+ const [loadingStages, setLoadingStages] = React.useState(false)
50
+ const [pipelineDialog, setPipelineDialog] = React.useState<PipelineDialogState>(null)
51
+ const [stageDialog, setStageDialog] = React.useState<StageDialogState>(null)
52
+ const [pipelineName, setPipelineName] = React.useState('')
53
+ const [pipelineIsDefault, setPipelineIsDefault] = React.useState(false)
54
+ const [stageName, setStageName] = React.useState('')
55
+ const [stageColor, setStageColor] = React.useState<string | null>(null)
56
+ const [stageIcon, setStageIcon] = React.useState<string | null>(null)
57
+ const [saving, setSaving] = React.useState(false)
58
+
59
+ const selectedPipeline = React.useMemo(
60
+ () => pipelines.find((p) => p.id === selectedPipelineId) ?? null,
61
+ [pipelines, selectedPipelineId],
62
+ )
63
+
64
+ const loadPipelines = React.useCallback(async () => {
65
+ setLoadingPipelines(true)
66
+ try {
67
+ const result = await apiCall<{ items: Pipeline[] }>('/api/customers/pipelines')
68
+ if (result.ok && result.result?.items) {
69
+ const items = result.result.items
70
+ setPipelines(items)
71
+ if (!selectedPipelineId && items.length > 0) {
72
+ const defaultPipeline = items.find((p) => p.isDefault) ?? items[0]
73
+ setSelectedPipelineId(defaultPipeline.id)
74
+ }
75
+ }
76
+ } catch {
77
+ flash(t('customers.config.pipelineStages.errorLoadPipelines', 'Failed to load pipelines'), 'error')
78
+ } finally {
79
+ setLoadingPipelines(false)
80
+ }
81
+ }, [selectedPipelineId, t])
82
+
83
+ const loadStages = React.useCallback(async (pipelineId: string) => {
84
+ setLoadingStages(true)
85
+ try {
86
+ const result = await apiCall<{ items: PipelineStage[] }>(
87
+ `/api/customers/pipeline-stages?pipelineId=${encodeURIComponent(pipelineId)}`
88
+ )
89
+ if (result.ok && result.result?.items) {
90
+ setStages(result.result.items)
91
+ }
92
+ } catch {
93
+ flash(t('customers.config.pipelineStages.errorLoadStages', 'Failed to load pipeline stages'), 'error')
94
+ } finally {
95
+ setLoadingStages(false)
96
+ }
97
+ }, [t])
98
+
99
+ React.useEffect(() => {
100
+ void loadPipelines()
101
+ }, [loadPipelines])
102
+
103
+ React.useEffect(() => {
104
+ if (selectedPipelineId) {
105
+ void loadStages(selectedPipelineId)
106
+ } else {
107
+ setStages([])
108
+ }
109
+ }, [selectedPipelineId, loadStages])
110
+
111
+ function openCreatePipeline() {
112
+ setPipelineName('')
113
+ setPipelineIsDefault(false)
114
+ setPipelineDialog({ mode: 'create' })
115
+ }
116
+
117
+ function openEditPipeline(pipeline: Pipeline) {
118
+ setPipelineName(pipeline.name)
119
+ setPipelineIsDefault(pipeline.isDefault)
120
+ setPipelineDialog({ mode: 'edit', pipeline })
121
+ }
122
+
123
+ async function savePipeline() {
124
+ if (!pipelineName.trim()) return
125
+ setSaving(true)
126
+ try {
127
+ if (pipelineDialog?.mode === 'create') {
128
+ const result = await apiCall<{ id: string }>('/api/customers/pipelines', {
129
+ method: 'POST',
130
+ body: JSON.stringify({ name: pipelineName.trim(), isDefault: pipelineIsDefault }),
131
+ headers: { 'Content-Type': 'application/json' },
132
+ })
133
+ if (!result.ok) {
134
+ flash(t('customers.config.pipelineStages.errorCreatePipeline', 'Failed to create pipeline'), 'error')
135
+ return
136
+ }
137
+ flash(t('customers.config.pipelineStages.createdPipeline', 'Pipeline created'), 'success')
138
+ const newId = result.result?.id ?? null
139
+ await loadPipelines()
140
+ if (newId) setSelectedPipelineId(newId)
141
+ } else if (pipelineDialog?.mode === 'edit') {
142
+ const result = await apiCall('/api/customers/pipelines', {
143
+ method: 'PUT',
144
+ body: JSON.stringify({ id: pipelineDialog.pipeline.id, name: pipelineName.trim(), isDefault: pipelineIsDefault }),
145
+ headers: { 'Content-Type': 'application/json' },
146
+ })
147
+ if (!result.ok) {
148
+ flash(t('customers.config.pipelineStages.errorUpdatePipeline', 'Failed to update pipeline'), 'error')
149
+ return
150
+ }
151
+ flash(t('customers.config.pipelineStages.updatedPipeline', 'Pipeline updated'), 'success')
152
+ await loadPipelines()
153
+ }
154
+ setPipelineDialog(null)
155
+ } finally {
156
+ setSaving(false)
157
+ }
158
+ }
159
+
160
+ async function deletePipeline(pipeline: Pipeline) {
161
+ const confirmed = await confirm({
162
+ title: t('customers.config.pipelineStages.deletePipelineTitle', 'Delete pipeline?'),
163
+ text: t(
164
+ 'customers.config.pipelineStages.deletePipelineDesc',
165
+ 'This pipeline and all its stages will be permanently removed. Deals assigned to it will lose their pipeline assignment.',
166
+ ),
167
+ confirmText: t('customers.config.pipelineStages.deletePipelineConfirm', 'Delete'),
168
+ variant: 'destructive',
169
+ })
170
+ if (!confirmed) return
171
+ const result = await apiCall('/api/customers/pipelines', {
172
+ method: 'DELETE',
173
+ body: JSON.stringify({ id: pipeline.id }),
174
+ headers: { 'Content-Type': 'application/json' },
175
+ })
176
+ if (!result.ok) {
177
+ const error = (result.result as { error?: string })?.error
178
+ flash(error ?? t('customers.config.pipelineStages.errorDeletePipeline', 'Failed to delete pipeline'), 'error')
179
+ return
180
+ }
181
+ flash(t('customers.config.pipelineStages.deletedPipeline', 'Pipeline deleted'), 'success')
182
+ if (selectedPipelineId === pipeline.id) setSelectedPipelineId(null)
183
+ await loadPipelines()
184
+ }
185
+
186
+ function openCreateStage() {
187
+ setStageName('')
188
+ setStageColor(null)
189
+ setStageIcon(null)
190
+ setStageDialog({ mode: 'create' })
191
+ }
192
+
193
+ function openEditStage(stage: PipelineStage) {
194
+ setStageName(stage.label)
195
+ setStageColor(stage.color)
196
+ setStageIcon(stage.icon)
197
+ setStageDialog({ mode: 'edit', stage })
198
+ }
199
+
200
+ async function saveStage() {
201
+ if (!stageName.trim() || !selectedPipelineId) return
202
+ setSaving(true)
203
+ try {
204
+ if (stageDialog?.mode === 'create') {
205
+ const result = await apiCall('/api/customers/pipeline-stages', {
206
+ method: 'POST',
207
+ body: JSON.stringify({ pipelineId: selectedPipelineId, label: stageName.trim(), color: stageColor, icon: stageIcon }),
208
+ headers: { 'Content-Type': 'application/json' },
209
+ })
210
+ if (!result.ok) {
211
+ flash(t('customers.config.pipelineStages.errorCreateStage', 'Failed to create stage'), 'error')
212
+ return
213
+ }
214
+ flash(t('customers.config.pipelineStages.createdStage', 'Stage created'), 'success')
215
+ } else if (stageDialog?.mode === 'edit') {
216
+ const result = await apiCall('/api/customers/pipeline-stages', {
217
+ method: 'PUT',
218
+ body: JSON.stringify({ id: stageDialog.stage.id, label: stageName.trim(), color: stageColor, icon: stageIcon }),
219
+ headers: { 'Content-Type': 'application/json' },
220
+ })
221
+ if (!result.ok) {
222
+ flash(t('customers.config.pipelineStages.errorUpdateStage', 'Failed to update stage'), 'error')
223
+ return
224
+ }
225
+ flash(t('customers.config.pipelineStages.updatedStage', 'Stage updated'), 'success')
226
+ }
227
+ setStageDialog(null)
228
+ await loadStages(selectedPipelineId)
229
+ } finally {
230
+ setSaving(false)
231
+ }
232
+ }
233
+
234
+ async function deleteStage(stage: PipelineStage) {
235
+ const confirmed = await confirm({
236
+ title: t('customers.config.pipelineStages.deleteStagTitle', 'Delete stage?'),
237
+ text: t(
238
+ 'customers.config.pipelineStages.deleteStageDesc',
239
+ 'This stage will be permanently removed. Deals assigned to it will lose their stage assignment.',
240
+ ),
241
+ confirmText: t('customers.config.pipelineStages.deleteStageConfirm', 'Delete'),
242
+ variant: 'destructive',
243
+ })
244
+ if (!confirmed) return
245
+ const result = await apiCall('/api/customers/pipeline-stages', {
246
+ method: 'DELETE',
247
+ body: JSON.stringify({ id: stage.id }),
248
+ headers: { 'Content-Type': 'application/json' },
249
+ })
250
+ if (!result.ok) {
251
+ const error = (result.result as { error?: string })?.error
252
+ flash(error ?? t('customers.config.pipelineStages.errorDeleteStage', 'Failed to delete stage'), 'error')
253
+ return
254
+ }
255
+ flash(t('customers.config.pipelineStages.deletedStage', 'Stage deleted'), 'success')
256
+ if (selectedPipelineId) await loadStages(selectedPipelineId)
257
+ }
258
+
259
+ async function moveStage(index: number, direction: 'up' | 'down') {
260
+ const nextIndex = direction === 'up' ? index - 1 : index + 1
261
+ if (nextIndex < 0 || nextIndex >= stages.length) return
262
+
263
+ const reordered = [...stages]
264
+ const [moved] = reordered.splice(index, 1)
265
+ reordered.splice(nextIndex, 0, moved)
266
+
267
+ const updated = reordered.map((stage, i) => ({ ...stage, order: i }))
268
+ setStages(updated)
269
+
270
+ const result = await apiCall('/api/customers/pipeline-stages/reorder', {
271
+ method: 'POST',
272
+ body: JSON.stringify({ stages: updated.map((s) => ({ id: s.id, order: s.order })) }),
273
+ headers: { 'Content-Type': 'application/json' },
274
+ })
275
+ if (!result.ok) {
276
+ flash(t('customers.config.pipelineStages.errorReorder', 'Failed to reorder stages'), 'error')
277
+ if (selectedPipelineId) await loadStages(selectedPipelineId)
278
+ }
279
+ }
280
+
281
+ const appearanceLabels = React.useMemo<AppearanceSelectorLabels>(() => ({
282
+ colorLabel: t('customers.config.pipelineStages.colorLabel', 'Color'),
283
+ colorHelp: t('customers.config.pipelineStages.colorHelp', 'Pick a highlight color for this entry.'),
284
+ colorClearLabel: t('customers.config.pipelineStages.colorClear', 'Remove color'),
285
+ iconLabel: t('customers.config.pipelineStages.iconLabel', 'Icon'),
286
+ iconPlaceholder: t('customers.config.pipelineStages.iconPlaceholder', 'Type an emoji or pick one of the suggestions.'),
287
+ iconPickerTriggerLabel: t('customers.config.pipelineStages.iconBrowse', 'Browse icons and emojis'),
288
+ iconSearchPlaceholder: t('customers.config.pipelineStages.iconSearchPlaceholder', 'Search icons or emojis…'),
289
+ iconSearchEmptyLabel: t('customers.config.pipelineStages.iconSearchEmpty', 'No icons match your search.'),
290
+ iconSuggestionsLabel: t('customers.config.pipelineStages.iconSuggestions', 'Suggestions'),
291
+ iconClearLabel: t('customers.config.pipelineStages.iconClear', 'Remove icon'),
292
+ previewEmptyLabel: t('customers.config.pipelineStages.previewEmpty', 'None'),
293
+ }), [t])
294
+
295
+ return (
296
+ <Page>
297
+ <PageBody>
298
+ <div className="space-y-6 max-w-2xl">
299
+ <div className="flex items-center justify-between">
300
+ <h2 className="text-lg font-semibold">
301
+ {t('customers.config.pipelineStages.title', 'Pipeline stages')}
302
+ </h2>
303
+ <a
304
+ href="/backend/customers/deals/pipeline"
305
+ className="text-sm text-muted-foreground hover:underline"
306
+ >
307
+ {t('customers.config.pipelineStages.viewBoard', 'View pipeline board')} →
308
+ </a>
309
+ </div>
310
+
311
+ {loadingPipelines ? (
312
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
313
+ <Spinner size="sm" />
314
+ {t('customers.config.pipelineStages.loadingPipelines', 'Loading pipelines…')}
315
+ </div>
316
+ ) : (
317
+ <>
318
+ <div className="flex items-center gap-3">
319
+ <select
320
+ className="flex h-9 w-full max-w-xs rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
321
+ value={selectedPipelineId ?? ''}
322
+ onChange={(e) => setSelectedPipelineId(e.target.value || null)}
323
+ >
324
+ {pipelines.length === 0 && (
325
+ <option value="">
326
+ {t('customers.config.pipelineStages.noPipelines', 'No pipelines yet')}
327
+ </option>
328
+ )}
329
+ {pipelines.map((p) => (
330
+ <option key={p.id} value={p.id}>
331
+ {p.name}{p.isDefault ? ` (${t('customers.config.pipelineStages.default', 'default')})` : ''}
332
+ </option>
333
+ ))}
334
+ </select>
335
+ {selectedPipeline && (
336
+ <>
337
+ <Button variant="outline" size="sm" onClick={() => openEditPipeline(selectedPipeline)}>
338
+ {t('customers.config.pipelineStages.editPipeline', 'Edit')}
339
+ </Button>
340
+ <Button
341
+ variant="outline"
342
+ size="sm"
343
+ className="text-destructive"
344
+ onClick={() => deletePipeline(selectedPipeline)}
345
+ >
346
+ {t('customers.config.pipelineStages.deletePipeline', 'Delete')}
347
+ </Button>
348
+ </>
349
+ )}
350
+ <Button variant="outline" size="sm" onClick={openCreatePipeline}>
351
+ {t('customers.config.pipelineStages.addPipeline', '+ Add pipeline')}
352
+ </Button>
353
+ </div>
354
+
355
+ {selectedPipelineId && (
356
+ <div className="space-y-3">
357
+ <div className="flex items-center justify-between">
358
+ <h3 className="text-sm font-medium text-muted-foreground">
359
+ {t('customers.config.pipelineStages.stagesTitle', 'Stages')}
360
+ </h3>
361
+ <Button size="sm" onClick={openCreateStage}>
362
+ {t('customers.config.pipelineStages.addStage', '+ Add stage')}
363
+ </Button>
364
+ </div>
365
+
366
+ {loadingStages ? (
367
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
368
+ <Spinner size="sm" />
369
+ {t('customers.config.pipelineStages.loadingStages', 'Loading stages…')}
370
+ </div>
371
+ ) : stages.length === 0 ? (
372
+ <p className="text-sm text-muted-foreground">
373
+ {t('customers.config.pipelineStages.noStages', 'No stages yet. Add your first stage.')}
374
+ </p>
375
+ ) : (
376
+ <div className="divide-y rounded-md border">
377
+ {stages.map((stage, index) => (
378
+ <div key={stage.id} className="flex items-center gap-3 px-4 py-3">
379
+ <div className="flex flex-col gap-1">
380
+ <button
381
+ type="button"
382
+ className="text-muted-foreground hover:text-foreground disabled:opacity-30"
383
+ onClick={() => moveStage(index, 'up')}
384
+ disabled={index === 0}
385
+ aria-label={t('customers.config.pipelineStages.moveUp', 'Move up')}
386
+ >
387
+
388
+ </button>
389
+ <button
390
+ type="button"
391
+ className="text-muted-foreground hover:text-foreground disabled:opacity-30"
392
+ onClick={() => moveStage(index, 'down')}
393
+ disabled={index === stages.length - 1}
394
+ aria-label={t('customers.config.pipelineStages.moveDown', 'Move down')}
395
+ >
396
+
397
+ </button>
398
+ </div>
399
+ <span className="flex-1 text-sm flex items-center gap-2">
400
+ {stage.color ? renderDictionaryColor(stage.color) : null}
401
+ {stage.icon ? renderDictionaryIcon(stage.icon) : null}
402
+ {stage.label}
403
+ </span>
404
+ <Button variant="ghost" size="sm" onClick={() => openEditStage(stage)}>
405
+ {t('customers.config.pipelineStages.editStage', 'Edit')}
406
+ </Button>
407
+ <Button
408
+ variant="ghost"
409
+ size="sm"
410
+ className="text-destructive"
411
+ onClick={() => deleteStage(stage)}
412
+ >
413
+ {t('customers.config.pipelineStages.deleteStage', 'Delete')}
414
+ </Button>
415
+ </div>
416
+ ))}
417
+ </div>
418
+ )}
419
+ </div>
420
+ )}
421
+ </>
422
+ )}
423
+ </div>
424
+
425
+ <Dialog open={pipelineDialog !== null} onOpenChange={(open) => { if (!open) setPipelineDialog(null) }}>
426
+ <DialogContent>
427
+ <DialogHeader>
428
+ <DialogTitle>
429
+ {pipelineDialog?.mode === 'create'
430
+ ? t('customers.config.pipelineStages.createPipelineTitle', 'Create pipeline')
431
+ : t('customers.config.pipelineStages.editPipelineTitle', 'Edit pipeline')}
432
+ </DialogTitle>
433
+ </DialogHeader>
434
+ <div className="space-y-4 py-2">
435
+ <div className="space-y-1">
436
+ <label className="text-sm font-medium">
437
+ {t('customers.config.pipelineStages.pipelineName', 'Name')}
438
+ </label>
439
+ <Input
440
+ value={pipelineName}
441
+ onChange={(e) => setPipelineName(e.target.value)}
442
+ placeholder={t('customers.config.pipelineStages.pipelineNamePlaceholder', 'e.g. Sales Pipeline')}
443
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); void savePipeline() } }}
444
+ autoFocus
445
+ />
446
+ </div>
447
+ <label className="flex items-center gap-2 text-sm cursor-pointer">
448
+ <input
449
+ type="checkbox"
450
+ checked={pipelineIsDefault}
451
+ onChange={(e) => setPipelineIsDefault(e.target.checked)}
452
+ />
453
+ {t('customers.config.pipelineStages.setAsDefault', 'Set as default pipeline')}
454
+ </label>
455
+ </div>
456
+ <DialogFooter>
457
+ <Button variant="outline" onClick={() => setPipelineDialog(null)} disabled={saving}>
458
+ {t('customers.config.pipelineStages.cancel', 'Cancel')}
459
+ </Button>
460
+ <Button onClick={() => void savePipeline()} disabled={saving || !pipelineName.trim()}>
461
+ {saving ? <Spinner size="sm" /> : t('customers.config.pipelineStages.save', 'Save')}
462
+ </Button>
463
+ </DialogFooter>
464
+ </DialogContent>
465
+ </Dialog>
466
+
467
+ <Dialog open={stageDialog !== null} onOpenChange={(open) => { if (!open) setStageDialog(null) }}>
468
+ <DialogContent>
469
+ <DialogHeader>
470
+ <DialogTitle>
471
+ {stageDialog?.mode === 'create'
472
+ ? t('customers.config.pipelineStages.createStageTitle', 'Create stage')
473
+ : t('customers.config.pipelineStages.editStageTitle', 'Edit stage')}
474
+ </DialogTitle>
475
+ </DialogHeader>
476
+ <div className="space-y-4 py-2">
477
+ <div className="space-y-1">
478
+ <label className="text-sm font-medium">
479
+ {t('customers.config.pipelineStages.stageName', 'Stage name')}
480
+ </label>
481
+ <Input
482
+ value={stageName}
483
+ onChange={(e) => setStageName(e.target.value)}
484
+ placeholder={t('customers.config.pipelineStages.stageNamePlaceholder', 'e.g. Qualification')}
485
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); void saveStage() } }}
486
+ autoFocus
487
+ />
488
+ </div>
489
+ <AppearanceSelector
490
+ color={stageColor}
491
+ icon={stageIcon}
492
+ onColorChange={setStageColor}
493
+ onIconChange={setStageIcon}
494
+ labels={appearanceLabels}
495
+ iconSuggestions={ICON_SUGGESTIONS}
496
+ />
497
+ </div>
498
+ <DialogFooter>
499
+ <Button variant="outline" onClick={() => setStageDialog(null)} disabled={saving}>
500
+ {t('customers.config.pipelineStages.cancel', 'Cancel')}
501
+ </Button>
502
+ <Button onClick={() => void saveStage()} disabled={saving || !stageName.trim()}>
503
+ {saving ? <Spinner size="sm" /> : t('customers.config.pipelineStages.save', 'Save')}
504
+ </Button>
505
+ </DialogFooter>
506
+ </DialogContent>
507
+ </Dialog>
508
+ {ConfirmDialogElement}
509
+ </PageBody>
510
+ </Page>
511
+ )
512
+ }
@@ -40,6 +40,8 @@ type DealDetailPayload = {
40
40
  description: string | null
41
41
  status: string | null
42
42
  pipelineStage: string | null
43
+ pipelineId: string | null
44
+ pipelineStageId: string | null
43
45
  valueAmount: string | null
44
46
  valueCurrency: string | null
45
47
  probability: number | null
@@ -209,6 +211,8 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
209
211
  title: base.title,
210
212
  status: base.status ?? undefined,
211
213
  pipelineStage: base.pipelineStage ?? undefined,
214
+ pipelineId: base.pipelineId ?? undefined,
215
+ pipelineStageId: base.pipelineStageId ?? undefined,
212
216
  valueAmount: typeof base.valueAmount === 'number' ? base.valueAmount : undefined,
213
217
  valueCurrency: base.valueCurrency ?? undefined,
214
218
  probability: typeof base.probability === 'number' ? base.probability : undefined,
@@ -373,7 +377,9 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
373
377
  const statusLabel =
374
378
  resolveDictionaryLabel(data.deal.status, statusDictionaryMap) ??
375
379
  t('customers.deals.detail.noStatus', 'No status')
380
+ const statusDictEntry = data.deal.status ? statusDictionaryMap?.[data.deal.status] ?? null : null
376
381
  const pipelineLabel = resolveDictionaryLabel(data.deal.pipelineStage, pipelineDictionaryMap)
382
+ const pipelineDictEntry = data.deal.pipelineStage ? pipelineDictionaryMap?.[data.deal.pipelineStage] ?? null : null
377
383
 
378
384
  const peopleSummaryLabel =
379
385
  data.people.length === 1
@@ -458,11 +464,23 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
458
464
  </p>
459
465
  <p className="text-base font-semibold text-foreground">{probabilityLabel}</p>
460
466
  </div>
467
+ <div>
468
+ <p className="text-xs font-medium uppercase text-muted-foreground">
469
+ {t('customers.deals.detail.fields.status', 'Status')}
470
+ </p>
471
+ <p className="text-base text-foreground flex items-center gap-2">
472
+ {statusDictEntry?.color ? renderDictionaryColor(statusDictEntry.color) : null}
473
+ {statusDictEntry?.icon ? renderDictionaryIcon(statusDictEntry.icon) : null}
474
+ {statusLabel}
475
+ </p>
476
+ </div>
461
477
  <div>
462
478
  <p className="text-xs font-medium uppercase text-muted-foreground">
463
479
  {t('customers.deals.detail.fields.pipeline', 'Pipeline stage')}
464
480
  </p>
465
- <p className="text-base text-foreground">
481
+ <p className="text-base text-foreground flex items-center gap-2">
482
+ {pipelineDictEntry?.color ? renderDictionaryColor(pipelineDictEntry.color) : null}
483
+ {pipelineDictEntry?.icon ? renderDictionaryIcon(pipelineDictEntry.icon) : null}
466
484
  {pipelineLabel ?? t('customers.deals.detail.noValue', 'Not provided')}
467
485
  </p>
468
486
  </div>
@@ -627,6 +645,8 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
627
645
  title: data.deal.title ?? '',
628
646
  status: data.deal.status ?? '',
629
647
  pipelineStage: data.deal.pipelineStage ?? '',
648
+ pipelineId: data.deal.pipelineId ?? '',
649
+ pipelineStageId: data.deal.pipelineStageId ?? '',
630
650
  valueAmount: data.deal.valueAmount ? Number(data.deal.valueAmount) : null,
631
651
  valueCurrency: data.deal.valueCurrency ?? undefined,
632
652
  probability: data.deal.probability ?? null,
@@ -36,6 +36,8 @@ type DealRow = {
36
36
  title: string
37
37
  status?: string | null
38
38
  pipelineStage?: string | null
39
+ pipelineStageId?: string | null
40
+ pipelineId?: string | null
39
41
  valueAmount?: number | null
40
42
  valueCurrency?: string | null
41
43
  probability?: number | null
@@ -442,6 +444,8 @@ export default function CustomersDealsPage() {
442
444
  'pipeline-stages': {},
443
445
  })
444
446
 
447
+ const [pipelineNames, setPipelineNames] = React.useState<Record<string, string>>({})
448
+
445
449
  const fetchDictionaryEntries = React.useCallback(
446
450
  async (kind: DictionaryKey) => {
447
451
  try {
@@ -464,6 +468,22 @@ export default function CustomersDealsPage() {
464
468
  return () => { cancelled = true }
465
469
  }, [fetchDictionaryEntries, reloadToken])
466
470
 
471
+ React.useEffect(() => {
472
+ let cancelled = false
473
+ async function loadPipelines() {
474
+ try {
475
+ const call = await apiCall<{ items?: Array<{ id: string; name: string }> }>('/api/customers/pipelines')
476
+ if (cancelled || !call.ok) return
477
+ const items = Array.isArray(call.result?.items) ? call.result.items : []
478
+ const map: Record<string, string> = {}
479
+ items.forEach((p) => { if (p.id && p.name) map[p.id] = p.name })
480
+ setPipelineNames(map)
481
+ } catch {}
482
+ }
483
+ loadPipelines().catch(() => {})
484
+ return () => { cancelled = true }
485
+ }, [reloadToken, scopeVersion])
486
+
467
487
  React.useEffect(() => {
468
488
  peopleCacheRef.current.clear()
469
489
  companiesCacheRef.current.clear()
@@ -814,6 +834,14 @@ export default function CustomersDealsPage() {
814
834
  header: t('customers.deals.list.columns.pipelineStage'),
815
835
  cell: ({ row }) => renderDictionaryCell('pipeline-stages', row.original.pipelineStage),
816
836
  },
837
+ {
838
+ accessorKey: 'pipelineId',
839
+ header: t('customers.deals.list.columns.pipeline', 'Pipeline'),
840
+ cell: ({ row }) => {
841
+ const name = row.original.pipelineId ? pipelineNames[row.original.pipelineId] : null
842
+ return name ? <span className="text-sm">{name}</span> : noValue
843
+ },
844
+ },
817
845
  {
818
846
  accessorKey: 'valueAmount',
819
847
  header: t('customers.deals.list.columns.value'),
@@ -864,7 +892,7 @@ export default function CustomersDealsPage() {
864
892
  },
865
893
  ...customColumns,
866
894
  ]
867
- }, [customFieldDefs, dictionaryMaps, t])
895
+ }, [customFieldDefs, dictionaryMaps, pipelineNames, t])
868
896
 
869
897
  return (
870
898
  <Page>
@@ -950,6 +978,8 @@ function mapDeal(item: Record<string, unknown>): DealRow | null {
950
978
  const title = typeof item.title === 'string' ? item.title : ''
951
979
  const status = typeof item.status === 'string' ? item.status : null
952
980
  const pipelineStage = typeof item.pipeline_stage === 'string' ? item.pipeline_stage : null
981
+ const pipelineStageId = typeof item.pipeline_stage_id === 'string' ? item.pipeline_stage_id : null
982
+ const pipelineId = typeof item.pipeline_id === 'string' ? item.pipeline_id : null
953
983
  const valueAmountRaw = item.value_amount
954
984
  const valueAmount =
955
985
  typeof valueAmountRaw === 'number'
@@ -1001,6 +1031,8 @@ function mapDeal(item: Record<string, unknown>): DealRow | null {
1001
1031
  title,
1002
1032
  status,
1003
1033
  pipelineStage,
1034
+ pipelineStageId,
1035
+ pipelineId,
1004
1036
  valueAmount,
1005
1037
  valueCurrency,
1006
1038
  probability,