@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.
- package/dist/generated/entities/customer_deal/index.js +4 -0
- package/dist/generated/entities/customer_deal/index.js.map +2 -2
- package/dist/generated/entities/customer_pipeline/index.js +17 -0
- package/dist/generated/entities/customer_pipeline/index.js.map +7 -0
- package/dist/generated/entities/customer_pipeline_stage/index.js +19 -0
- package/dist/generated/entities/customer_pipeline_stage/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +2 -0
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +4 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/customers/acl.js +2 -0
- package/dist/modules/customers/acl.js.map +2 -2
- package/dist/modules/customers/api/deals/[id]/route.js +4 -0
- package/dist/modules/customers/api/deals/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/deals/route.js +12 -0
- package/dist/modules/customers/api/deals/route.js.map +2 -2
- package/dist/modules/customers/api/dictionaries/[kind]/route.js +20 -1
- package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
- package/dist/modules/customers/api/pipeline-stages/reorder/route.js +69 -0
- package/dist/modules/customers/api/pipeline-stages/reorder/route.js.map +7 -0
- package/dist/modules/customers/api/pipeline-stages/route.js +275 -0
- package/dist/modules/customers/api/pipeline-stages/route.js.map +7 -0
- package/dist/modules/customers/api/pipelines/route.js +245 -0
- package/dist/modules/customers/api/pipelines/route.js.map +7 -0
- package/dist/modules/customers/backend/config/customers/page.js +2 -0
- package/dist/modules/customers/backend/config/customers/page.js.map +2 -2
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js +439 -0
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js.map +7 -0
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js +17 -0
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/[id]/page.js +19 -1
- package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/page.js +35 -1
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +102 -74
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
- package/dist/modules/customers/cli.js +28 -2
- package/dist/modules/customers/cli.js.map +2 -2
- package/dist/modules/customers/commands/deals.js +34 -2
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/commands/index.js +2 -0
- package/dist/modules/customers/commands/index.js.map +2 -2
- package/dist/modules/customers/commands/pipeline-stages.js +126 -0
- package/dist/modules/customers/commands/pipeline-stages.js.map +7 -0
- package/dist/modules/customers/commands/pipelines.js +87 -0
- package/dist/modules/customers/commands/pipelines.js.map +7 -0
- package/dist/modules/customers/components/DictionarySettings.js +0 -5
- package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
- package/dist/modules/customers/components/PipelineSettings.js +474 -0
- package/dist/modules/customers/components/PipelineSettings.js.map +7 -0
- package/dist/modules/customers/components/detail/DealForm.js +84 -12
- package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
- package/dist/modules/customers/data/entities.js +78 -0
- package/dist/modules/customers/data/entities.js.map +2 -2
- package/dist/modules/customers/data/validators.js +44 -0
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/migrations/Migration20260218191730.js +77 -0
- package/dist/modules/customers/migrations/Migration20260218191730.js.map +7 -0
- package/dist/modules/customers/setup.js +7 -3
- package/dist/modules/customers/setup.js.map +2 -2
- package/dist/modules/translations/api/[entityType]/[entityId]/route.js +46 -44
- package/dist/modules/translations/api/[entityType]/[entityId]/route.js.map +2 -2
- package/dist/modules/translations/api/context.js +10 -1
- package/dist/modules/translations/api/context.js.map +2 -2
- package/dist/modules/translations/commands/index.js +2 -0
- package/dist/modules/translations/commands/index.js.map +7 -0
- package/dist/modules/translations/commands/translations.js +160 -0
- package/dist/modules/translations/commands/translations.js.map +7 -0
- package/dist/modules/translations/index.js +1 -0
- package/dist/modules/translations/index.js.map +2 -2
- package/dist/modules/workflows/migrations/Migration20260222205305.js +14 -0
- package/dist/modules/workflows/migrations/Migration20260222205305.js.map +7 -0
- package/generated/entities/customer_deal/index.ts +2 -0
- package/generated/entities/customer_pipeline/index.ts +7 -0
- package/generated/entities/customer_pipeline_stage/index.ts +8 -0
- package/generated/entities.ids.generated.ts +2 -0
- package/generated/entity-fields-registry.ts +4 -0
- package/package.json +2 -2
- package/src/modules/customers/acl.ts +2 -0
- package/src/modules/customers/api/deals/[id]/route.ts +4 -0
- package/src/modules/customers/api/deals/route.ts +12 -0
- package/src/modules/customers/api/dictionaries/[kind]/route.ts +21 -1
- package/src/modules/customers/api/pipeline-stages/reorder/route.ts +71 -0
- package/src/modules/customers/api/pipeline-stages/route.ts +296 -0
- package/src/modules/customers/api/pipelines/route.ts +261 -0
- package/src/modules/customers/backend/config/customers/page.tsx +2 -0
- package/src/modules/customers/backend/config/customers/pipeline-stages/page.meta.ts +13 -0
- package/src/modules/customers/backend/config/customers/pipeline-stages/page.tsx +512 -0
- package/src/modules/customers/backend/customers/deals/[id]/page.tsx +21 -1
- package/src/modules/customers/backend/customers/deals/page.tsx +33 -1
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +119 -79
- package/src/modules/customers/cli.ts +29 -1
- package/src/modules/customers/commands/deals.ts +44 -1
- package/src/modules/customers/commands/index.ts +2 -0
- package/src/modules/customers/commands/pipeline-stages.ts +156 -0
- package/src/modules/customers/commands/pipelines.ts +105 -0
- package/src/modules/customers/components/DictionarySettings.tsx +0 -5
- package/src/modules/customers/components/PipelineSettings.tsx +570 -0
- package/src/modules/customers/components/detail/DealForm.tsx +89 -11
- package/src/modules/customers/data/entities.ts +64 -0
- package/src/modules/customers/data/validators.ts +57 -0
- package/src/modules/customers/i18n/de.json +4 -0
- package/src/modules/customers/i18n/en.json +4 -0
- package/src/modules/customers/i18n/es.json +4 -0
- package/src/modules/customers/i18n/pl.json +5 -1
- package/src/modules/customers/migrations/Migration20260218191730.ts +84 -0
- package/src/modules/customers/setup.ts +5 -1
- package/src/modules/translations/api/[entityType]/[entityId]/route.ts +65 -60
- package/src/modules/translations/api/context.ts +12 -0
- package/src/modules/translations/commands/index.ts +1 -0
- package/src/modules/translations/commands/translations.ts +253 -0
- package/src/modules/translations/index.ts +1 -0
- 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,
|