@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,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
|
+
}
|