@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
|
@@ -11,11 +11,6 @@ import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
|
11
11
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
12
12
|
import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
|
|
13
13
|
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
14
|
-
import {
|
|
15
|
-
customerDictionaryQueryOptions,
|
|
16
|
-
type CustomerDictionaryQueryData,
|
|
17
|
-
} from '../../../../components/detail/hooks/useCustomerDictionary'
|
|
18
|
-
import { renderDictionaryColor, renderDictionaryIcon } from '../../../../lib/dictionaries'
|
|
19
14
|
|
|
20
15
|
type DealAssociation = { id: string; label: string }
|
|
21
16
|
|
|
@@ -24,6 +19,8 @@ type DealRecord = {
|
|
|
24
19
|
title: string
|
|
25
20
|
status: string | null
|
|
26
21
|
pipelineStage: string | null
|
|
22
|
+
pipelineId: string | null
|
|
23
|
+
pipelineStageId: string | null
|
|
27
24
|
valueAmount: number | null
|
|
28
25
|
valueCurrency: string | null
|
|
29
26
|
probability: number | null
|
|
@@ -51,10 +48,13 @@ type StageDefinition = {
|
|
|
51
48
|
|
|
52
49
|
type SortOption = 'probability' | 'createdAt' | 'expectedCloseAt'
|
|
53
50
|
|
|
51
|
+
type PipelineRecord = { id: string; name: string; isDefault: boolean }
|
|
52
|
+
type PipelineStageRecord = { id: string; label: string; order: number; pipelineId: string }
|
|
53
|
+
|
|
54
54
|
const DEALS_QUERY_LIMIT = 100
|
|
55
55
|
|
|
56
|
-
const dealsQueryKey = (scopeVersion: number) =>
|
|
57
|
-
['customers', 'deals', 'pipeline', `scope:${scopeVersion}`] as const
|
|
56
|
+
const dealsQueryKey = (scopeVersion: number, pipelineId: string | null) =>
|
|
57
|
+
['customers', 'deals', 'pipeline', `scope:${scopeVersion}`, `pipeline:${pipelineId ?? 'none'}`] as const
|
|
58
58
|
|
|
59
59
|
const sortOptions: SortOption[] = ['probability', 'createdAt', 'expectedCloseAt']
|
|
60
60
|
|
|
@@ -84,45 +84,24 @@ function normalizeTimestamp(value: unknown): { iso: string | null; ts: number |
|
|
|
84
84
|
return { iso: date.toISOString(), ts: date.getTime() }
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
function
|
|
88
|
-
|
|
87
|
+
function buildStageDefinitionsFromPipelineStages(
|
|
88
|
+
pipelineStages: PipelineStageRecord[],
|
|
89
89
|
deals: DealRecord[],
|
|
90
90
|
t: ReturnType<typeof useT>,
|
|
91
91
|
): StageDefinition[] {
|
|
92
|
-
const result: StageDefinition[] =
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const label = typeof entry.label === 'string' && entry.label.trim().length ? entry.label.trim() : entry.value
|
|
100
|
-
result.push({
|
|
101
|
-
id: `stage:${entry.value}:${index}`,
|
|
102
|
-
value: entry.value,
|
|
103
|
-
label,
|
|
104
|
-
color: typeof entry.color === 'string' ? entry.color : null,
|
|
105
|
-
icon: typeof entry.icon === 'string' ? entry.icon : null,
|
|
106
|
-
})
|
|
107
|
-
seen.add(entry.value)
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
const unknownStages = new Map<string, StageDefinition>()
|
|
111
|
-
deals.forEach((deal) => {
|
|
112
|
-
if (!deal.pipelineStage || seen.has(deal.pipelineStage)) return
|
|
113
|
-
if (unknownStages.has(deal.pipelineStage)) return
|
|
114
|
-
unknownStages.set(deal.pipelineStage, {
|
|
115
|
-
id: `stage:${deal.pipelineStage}`,
|
|
116
|
-
value: deal.pipelineStage,
|
|
117
|
-
label: deal.pipelineStage,
|
|
92
|
+
const result: StageDefinition[] = pipelineStages
|
|
93
|
+
.slice()
|
|
94
|
+
.sort((a, b) => a.order - b.order)
|
|
95
|
+
.map((stage) => ({
|
|
96
|
+
id: stage.id,
|
|
97
|
+
value: stage.id,
|
|
98
|
+
label: stage.label,
|
|
118
99
|
color: null,
|
|
119
100
|
icon: null,
|
|
120
|
-
})
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
unknownStages.forEach((entry) => result.push(entry))
|
|
101
|
+
}))
|
|
124
102
|
|
|
125
|
-
const
|
|
103
|
+
const knownIds = new Set(pipelineStages.map((s) => s.id))
|
|
104
|
+
const hasUnassigned = deals.some((deal) => !deal.pipelineStageId || !knownIds.has(deal.pipelineStageId))
|
|
126
105
|
if (hasUnassigned) {
|
|
127
106
|
result.push({
|
|
128
107
|
id: 'stage:__unassigned',
|
|
@@ -140,10 +119,10 @@ function createDealMap(deals: DealRecord[]): Map<string, DealRecord> {
|
|
|
140
119
|
return deals.reduce<Map<string, DealRecord>>((acc, deal) => acc.set(deal.id, deal), new Map())
|
|
141
120
|
}
|
|
142
121
|
|
|
143
|
-
function
|
|
122
|
+
function groupDealsByStageId(deals: DealRecord[]): Map<string | null, DealRecord[]> {
|
|
144
123
|
const byStage = new Map<string | null, DealRecord[]>()
|
|
145
124
|
deals.forEach((deal) => {
|
|
146
|
-
const stageKey = deal.
|
|
125
|
+
const stageKey = deal.pipelineStageId ?? null
|
|
147
126
|
const bucket = byStage.get(stageKey) ?? []
|
|
148
127
|
bucket.push(deal)
|
|
149
128
|
byStage.set(stageKey, bucket)
|
|
@@ -219,12 +198,48 @@ export default function SalesPipelinePage(): React.ReactElement {
|
|
|
219
198
|
const queryClient = useQueryClient()
|
|
220
199
|
const [sortBy, setSortBy] = React.useState<SortOption>('probability')
|
|
221
200
|
const [pendingDealId, setPendingDealId] = React.useState<string | null>(null)
|
|
222
|
-
const
|
|
201
|
+
const [selectedPipelineId, setSelectedPipelineId] = React.useState<string | null>(null)
|
|
223
202
|
|
|
224
|
-
const
|
|
203
|
+
const pipelinesQuery = useQuery<PipelineRecord[]>({
|
|
204
|
+
queryKey: ['customers', 'pipelines', `scope:${scopeVersion}`],
|
|
205
|
+
staleTime: 60_000,
|
|
206
|
+
queryFn: async () => {
|
|
207
|
+
const payload = await readApiResultOrThrow<{ items: PipelineRecord[] }>(
|
|
208
|
+
'/api/customers/pipelines',
|
|
209
|
+
undefined,
|
|
210
|
+
{ errorMessage: translate('customers.deals.pipeline.loadError', 'Failed to load pipelines.') },
|
|
211
|
+
)
|
|
212
|
+
return payload?.items ?? []
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
React.useEffect(() => {
|
|
217
|
+
if (selectedPipelineId) return
|
|
218
|
+
const pipelines = pipelinesQuery.data
|
|
219
|
+
if (!pipelines || !pipelines.length) return
|
|
220
|
+
const defaultPipeline = pipelines.find((p) => p.isDefault) ?? pipelines[0]
|
|
221
|
+
if (defaultPipeline) setSelectedPipelineId(defaultPipeline.id)
|
|
222
|
+
}, [pipelinesQuery.data, selectedPipelineId])
|
|
223
|
+
|
|
224
|
+
const stagesQuery = useQuery<PipelineStageRecord[]>({
|
|
225
|
+
queryKey: ['customers', 'pipeline-stages', `scope:${scopeVersion}`, `pipeline:${selectedPipelineId}`],
|
|
226
|
+
enabled: !!selectedPipelineId,
|
|
227
|
+
staleTime: 30_000,
|
|
228
|
+
queryFn: async () => {
|
|
229
|
+
const payload = await readApiResultOrThrow<{ items: PipelineStageRecord[] }>(
|
|
230
|
+
`/api/customers/pipeline-stages?pipelineId=${encodeURIComponent(selectedPipelineId!)}`,
|
|
231
|
+
undefined,
|
|
232
|
+
{ errorMessage: translate('customers.deals.pipeline.loadError', 'Failed to load stages.') },
|
|
233
|
+
)
|
|
234
|
+
return payload?.items ?? []
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
const dealsKey = React.useMemo(() => dealsQueryKey(scopeVersion, selectedPipelineId), [scopeVersion, selectedPipelineId])
|
|
225
239
|
|
|
226
240
|
const dealsQuery = useQuery<DealsQueryData>({
|
|
227
241
|
queryKey: dealsKey,
|
|
242
|
+
enabled: !!selectedPipelineId,
|
|
228
243
|
staleTime: 30_000,
|
|
229
244
|
queryFn: async () => {
|
|
230
245
|
const search = new URLSearchParams()
|
|
@@ -232,6 +247,7 @@ export default function SalesPipelinePage(): React.ReactElement {
|
|
|
232
247
|
search.set('pageSize', String(DEALS_QUERY_LIMIT))
|
|
233
248
|
search.set('sortField', 'createdAt')
|
|
234
249
|
search.set('sortDir', 'desc')
|
|
250
|
+
if (selectedPipelineId) search.set('pipelineId', selectedPipelineId)
|
|
235
251
|
const payload = await readApiResultOrThrow<Record<string, unknown>>(
|
|
236
252
|
`/api/customers/deals?${search.toString()}`,
|
|
237
253
|
undefined,
|
|
@@ -296,6 +312,8 @@ export default function SalesPipelinePage(): React.ReactElement {
|
|
|
296
312
|
title,
|
|
297
313
|
status,
|
|
298
314
|
pipelineStage: stage,
|
|
315
|
+
pipelineId: typeof data.pipeline_id === 'string' ? data.pipeline_id : null,
|
|
316
|
+
pipelineStageId: typeof data.pipeline_stage_id === 'string' ? data.pipeline_stage_id : null,
|
|
299
317
|
valueAmount: amount,
|
|
300
318
|
valueCurrency: currency,
|
|
301
319
|
probability,
|
|
@@ -317,10 +335,10 @@ export default function SalesPipelinePage(): React.ReactElement {
|
|
|
317
335
|
const deals = dealsQuery.data?.deals ?? []
|
|
318
336
|
const total = dealsQuery.data?.total ?? deals.length
|
|
319
337
|
const dealMap = React.useMemo(() => createDealMap(deals), [deals])
|
|
320
|
-
const groupedDeals = React.useMemo(() =>
|
|
338
|
+
const groupedDeals = React.useMemo(() => groupDealsByStageId(deals), [deals])
|
|
321
339
|
const stages = React.useMemo(
|
|
322
|
-
() =>
|
|
323
|
-
[
|
|
340
|
+
() => buildStageDefinitionsFromPipelineStages(stagesQuery.data ?? [], deals, t),
|
|
341
|
+
[stagesQuery.data, deals, t],
|
|
324
342
|
)
|
|
325
343
|
|
|
326
344
|
const dateFormatter = React.useMemo(
|
|
@@ -332,25 +350,25 @@ export default function SalesPipelinePage(): React.ReactElement {
|
|
|
332
350
|
)
|
|
333
351
|
|
|
334
352
|
const updateStageMutation = useMutation({
|
|
335
|
-
mutationFn: async ({ id,
|
|
353
|
+
mutationFn: async ({ id, pipelineStageId }: { id: string; pipelineStageId: string }) => {
|
|
336
354
|
await apiCallOrThrow(
|
|
337
355
|
'/api/customers/deals',
|
|
338
356
|
{
|
|
339
357
|
method: 'PUT',
|
|
340
358
|
headers: { 'content-type': 'application/json' },
|
|
341
|
-
body: JSON.stringify({ id,
|
|
359
|
+
body: JSON.stringify({ id, pipelineStageId }),
|
|
342
360
|
},
|
|
343
361
|
{ errorMessage: translate('customers.deals.pipeline.moveError', 'Failed to update deal stage.') },
|
|
344
362
|
)
|
|
345
|
-
return { id,
|
|
363
|
+
return { id, pipelineStageId }
|
|
346
364
|
},
|
|
347
|
-
onMutate: async ({ id,
|
|
365
|
+
onMutate: async ({ id, pipelineStageId }) => {
|
|
348
366
|
setPendingDealId(id)
|
|
349
367
|
await queryClient.cancelQueries({ queryKey: dealsKey })
|
|
350
368
|
const previous = queryClient.getQueryData<DealsQueryData>(dealsKey)
|
|
351
369
|
if (previous) {
|
|
352
370
|
const nextDeals = previous.deals.map((deal) =>
|
|
353
|
-
deal.id === id ? { ...deal,
|
|
371
|
+
deal.id === id ? { ...deal, pipelineStageId } : deal,
|
|
354
372
|
)
|
|
355
373
|
queryClient.setQueryData<DealsQueryData>(dealsKey, { ...previous, deals: nextDeals })
|
|
356
374
|
}
|
|
@@ -410,10 +428,10 @@ export default function SalesPipelinePage(): React.ReactElement {
|
|
|
410
428
|
)
|
|
411
429
|
return
|
|
412
430
|
}
|
|
413
|
-
if (deal.
|
|
414
|
-
updateStageMutation.mutate({ id: dealId,
|
|
431
|
+
if (deal.pipelineStageId === stage.value) return
|
|
432
|
+
updateStageMutation.mutate({ id: dealId, pipelineStageId: stage.value })
|
|
415
433
|
},
|
|
416
|
-
[dealMap, draggingId,
|
|
434
|
+
[dealMap, draggingId, translate, updateStageMutation],
|
|
417
435
|
)
|
|
418
436
|
|
|
419
437
|
const handleDragOver = React.useCallback(
|
|
@@ -429,11 +447,6 @@ export default function SalesPipelinePage(): React.ReactElement {
|
|
|
429
447
|
return (
|
|
430
448
|
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3">
|
|
431
449
|
<div className="flex items-center gap-2">
|
|
432
|
-
{stage.icon ? (
|
|
433
|
-
<span className="inline-flex h-7 w-7 items-center justify-center rounded border border-border bg-muted">
|
|
434
|
-
{renderDictionaryIcon(stage.icon, 'h-4 w-4 text-muted-foreground')}
|
|
435
|
-
</span>
|
|
436
|
-
) : null}
|
|
437
450
|
<div className="flex flex-col">
|
|
438
451
|
<span className="text-sm font-medium">{stage.label}</span>
|
|
439
452
|
<span className="text-xs text-muted-foreground">
|
|
@@ -441,7 +454,6 @@ export default function SalesPipelinePage(): React.ReactElement {
|
|
|
441
454
|
</span>
|
|
442
455
|
</div>
|
|
443
456
|
</div>
|
|
444
|
-
{stage.color ? renderDictionaryColor(stage.color, 'h-3 w-3 rounded-full') : null}
|
|
445
457
|
</div>
|
|
446
458
|
)
|
|
447
459
|
}
|
|
@@ -462,27 +474,55 @@ export default function SalesPipelinePage(): React.ReactElement {
|
|
|
462
474
|
)}
|
|
463
475
|
</p>
|
|
464
476
|
</div>
|
|
465
|
-
<
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
477
|
+
<div className="flex items-center gap-4">
|
|
478
|
+
{pipelinesQuery.data && pipelinesQuery.data.length > 0 ? (
|
|
479
|
+
<label className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
|
480
|
+
<span>{translate('customers.deals.pipeline.switch.label', 'Pipeline')}</span>
|
|
481
|
+
<select
|
|
482
|
+
className="h-9 rounded-md border border-border bg-background px-3 text-sm text-foreground shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
483
|
+
value={selectedPipelineId ?? ''}
|
|
484
|
+
onChange={(e) => setSelectedPipelineId(e.target.value || null)}
|
|
485
|
+
>
|
|
486
|
+
{pipelinesQuery.data.map((p) => (
|
|
487
|
+
<option key={p.id} value={p.id}>{p.name}</option>
|
|
488
|
+
))}
|
|
489
|
+
</select>
|
|
490
|
+
</label>
|
|
491
|
+
) : null}
|
|
492
|
+
<Link
|
|
493
|
+
href="/backend/config/customers/pipeline-stages"
|
|
494
|
+
className="text-sm font-medium text-primary hover:underline"
|
|
471
495
|
>
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
<
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
{
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
496
|
+
{translate('customers.deals.pipeline.manageStages', 'Manage stages')}
|
|
497
|
+
</Link>
|
|
498
|
+
<label className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
|
499
|
+
<span>{translate('customers.deals.pipeline.sort.label', 'Sort by')}</span>
|
|
500
|
+
<select
|
|
501
|
+
className="h-9 rounded-md border border-border bg-background px-3 text-sm text-foreground shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
|
502
|
+
value={sortBy}
|
|
503
|
+
onChange={handleSortChange}
|
|
504
|
+
>
|
|
505
|
+
<option value="probability">
|
|
506
|
+
{translate('customers.deals.pipeline.sort.probability', 'Probability (high to low)')}
|
|
507
|
+
</option>
|
|
508
|
+
<option value="createdAt">
|
|
509
|
+
{translate('customers.deals.pipeline.sort.createdAt', 'Created (newest first)')}
|
|
510
|
+
</option>
|
|
511
|
+
<option value="expectedCloseAt">
|
|
512
|
+
{translate('customers.deals.pipeline.sort.expectedCloseAt', 'Expected close (soonest first)')}
|
|
513
|
+
</option>
|
|
514
|
+
</select>
|
|
515
|
+
</label>
|
|
516
|
+
</div>
|
|
483
517
|
</div>
|
|
484
518
|
|
|
485
|
-
{
|
|
519
|
+
{!selectedPipelineId ? (
|
|
520
|
+
<div className="flex h-[50vh] items-center justify-center">
|
|
521
|
+
<span className="text-sm text-muted-foreground">
|
|
522
|
+
{translate('customers.deals.pipeline.noPipeline', 'No pipeline selected. Create a pipeline in settings.')}
|
|
523
|
+
</span>
|
|
524
|
+
</div>
|
|
525
|
+
) : dealsQuery.isLoading ? (
|
|
486
526
|
<div className="flex h-[50vh] items-center justify-center">
|
|
487
527
|
<Spinner />
|
|
488
528
|
</div>
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
CustomerActivity,
|
|
23
23
|
CustomerAddress,
|
|
24
24
|
CustomerComment,
|
|
25
|
+
CustomerPipeline,
|
|
26
|
+
CustomerPipelineStage,
|
|
25
27
|
} from './data/entities'
|
|
26
28
|
import { ensureDictionaryEntry } from './commands/shared'
|
|
27
29
|
|
|
@@ -2793,7 +2795,33 @@ const seedStressTest: ModuleCli = {
|
|
|
2793
2795
|
},
|
|
2794
2796
|
}
|
|
2795
2797
|
|
|
2796
|
-
|
|
2798
|
+
async function seedDefaultPipeline(em: EntityManager, { tenantId, organizationId }: SeedArgs): Promise<void> {
|
|
2799
|
+
const existing = await em.findOne(CustomerPipeline, { tenantId, organizationId, isDefault: true })
|
|
2800
|
+
if (existing) return
|
|
2801
|
+
|
|
2802
|
+
const pipeline = em.create(CustomerPipeline, {
|
|
2803
|
+
tenantId,
|
|
2804
|
+
organizationId,
|
|
2805
|
+
name: 'Default Pipeline',
|
|
2806
|
+
isDefault: true,
|
|
2807
|
+
})
|
|
2808
|
+
em.persist(pipeline)
|
|
2809
|
+
await em.flush()
|
|
2810
|
+
|
|
2811
|
+
for (let i = 0; i < PIPELINE_STAGE_DEFAULTS.length; i++) {
|
|
2812
|
+
const entry = PIPELINE_STAGE_DEFAULTS[i]
|
|
2813
|
+
em.persist(em.create(CustomerPipelineStage, {
|
|
2814
|
+
tenantId,
|
|
2815
|
+
organizationId,
|
|
2816
|
+
pipelineId: pipeline.id,
|
|
2817
|
+
label: entry.label,
|
|
2818
|
+
order: i,
|
|
2819
|
+
}))
|
|
2820
|
+
}
|
|
2821
|
+
await em.flush()
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
export { seedCustomerDictionaries, seedCustomerExamples, seedCustomerStressTest, seedCurrencyDictionary, seedDefaultPipeline }
|
|
2797
2825
|
export type { SeedArgs as CustomerSeedArgs }
|
|
2798
2826
|
|
|
2799
2827
|
const customersCliCommands = [seedDictionaries, seedExamples, seedStressTest]
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from '@open-mercato/shared/lib/commands/helpers'
|
|
10
10
|
import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
11
11
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
12
|
-
import { CustomerDeal, CustomerDealPersonLink, CustomerDealCompanyLink } from '../data/entities'
|
|
12
|
+
import { CustomerDeal, CustomerDealPersonLink, CustomerDealCompanyLink, CustomerPipelineStage } from '../data/entities'
|
|
13
13
|
import {
|
|
14
14
|
dealCreateSchema,
|
|
15
15
|
dealUpdateSchema,
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
requireCustomerEntity,
|
|
23
23
|
ensureSameScope,
|
|
24
24
|
extractUndoPayload,
|
|
25
|
+
ensureDictionaryEntry,
|
|
25
26
|
} from './shared'
|
|
26
27
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
27
28
|
import {
|
|
@@ -53,6 +54,23 @@ const dealCrudEvents: CrudEventsConfig = {
|
|
|
53
54
|
}),
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
async function resolvePipelineStageValue(
|
|
58
|
+
em: EntityManager,
|
|
59
|
+
pipelineStageId: string,
|
|
60
|
+
tenantId: string,
|
|
61
|
+
organizationId: string,
|
|
62
|
+
): Promise<string | null> {
|
|
63
|
+
const stage = await em.findOne(CustomerPipelineStage, { id: pipelineStageId })
|
|
64
|
+
if (!stage) return null
|
|
65
|
+
const entry = await ensureDictionaryEntry(em, {
|
|
66
|
+
tenantId,
|
|
67
|
+
organizationId,
|
|
68
|
+
kind: 'pipeline_stage',
|
|
69
|
+
value: stage.label,
|
|
70
|
+
})
|
|
71
|
+
return entry?.value ?? stage.label
|
|
72
|
+
}
|
|
73
|
+
|
|
56
74
|
type DealSnapshot = {
|
|
57
75
|
deal: {
|
|
58
76
|
id: string
|
|
@@ -62,6 +80,8 @@ type DealSnapshot = {
|
|
|
62
80
|
description: string | null
|
|
63
81
|
status: string
|
|
64
82
|
pipelineStage: string | null
|
|
83
|
+
pipelineId: string | null
|
|
84
|
+
pipelineStageId: string | null
|
|
65
85
|
valueAmount: string | null
|
|
66
86
|
valueCurrency: string | null
|
|
67
87
|
probability: number | null
|
|
@@ -116,6 +136,8 @@ async function loadDealSnapshot(em: EntityManager, id: string): Promise<DealSnap
|
|
|
116
136
|
description: deal.description ?? null,
|
|
117
137
|
status: deal.status,
|
|
118
138
|
pipelineStage: deal.pipelineStage ?? null,
|
|
139
|
+
pipelineId: deal.pipelineId ?? null,
|
|
140
|
+
pipelineStageId: deal.pipelineStageId ?? null,
|
|
119
141
|
valueAmount: deal.valueAmount ?? null,
|
|
120
142
|
valueCurrency: deal.valueCurrency ?? null,
|
|
121
143
|
probability: deal.probability ?? null,
|
|
@@ -193,6 +215,8 @@ const createDealCommand: CommandHandler<DealCreateInput, { dealId: string }> = {
|
|
|
193
215
|
description: parsed.description ?? null,
|
|
194
216
|
status: parsed.status ?? 'open',
|
|
195
217
|
pipelineStage: parsed.pipelineStage ?? null,
|
|
218
|
+
pipelineId: parsed.pipelineId ?? null,
|
|
219
|
+
pipelineStageId: parsed.pipelineStageId ?? null,
|
|
196
220
|
valueAmount: toNumericString(parsed.valueAmount),
|
|
197
221
|
valueCurrency: parsed.valueCurrency ?? null,
|
|
198
222
|
probability: parsed.probability ?? null,
|
|
@@ -201,6 +225,12 @@ const createDealCommand: CommandHandler<DealCreateInput, { dealId: string }> = {
|
|
|
201
225
|
source: parsed.source ?? null,
|
|
202
226
|
})
|
|
203
227
|
em.persist(deal)
|
|
228
|
+
|
|
229
|
+
if (deal.pipelineStageId && !deal.pipelineStage) {
|
|
230
|
+
const resolved = await resolvePipelineStageValue(em, deal.pipelineStageId, parsed.tenantId, parsed.organizationId)
|
|
231
|
+
if (resolved) deal.pipelineStage = resolved
|
|
232
|
+
}
|
|
233
|
+
|
|
204
234
|
await em.flush()
|
|
205
235
|
|
|
206
236
|
await syncDealPeople(em, deal, parsed.personIds ?? [])
|
|
@@ -290,6 +320,13 @@ const updateDealCommand: CommandHandler<DealUpdateInput, { dealId: string }> = {
|
|
|
290
320
|
if (parsed.description !== undefined) record.description = parsed.description ?? null
|
|
291
321
|
if (parsed.status !== undefined) record.status = parsed.status ?? record.status
|
|
292
322
|
if (parsed.pipelineStage !== undefined) record.pipelineStage = parsed.pipelineStage ?? null
|
|
323
|
+
if (parsed.pipelineId !== undefined) record.pipelineId = parsed.pipelineId ?? null
|
|
324
|
+
if (parsed.pipelineStageId !== undefined) record.pipelineStageId = parsed.pipelineStageId ?? null
|
|
325
|
+
|
|
326
|
+
if (record.pipelineStageId && (parsed.pipelineStageId !== undefined || !record.pipelineStage)) {
|
|
327
|
+
const resolved = await resolvePipelineStageValue(em, record.pipelineStageId, record.tenantId, record.organizationId)
|
|
328
|
+
if (resolved) record.pipelineStage = resolved
|
|
329
|
+
}
|
|
293
330
|
if (parsed.valueAmount !== undefined) record.valueAmount = toNumericString(parsed.valueAmount)
|
|
294
331
|
if (parsed.valueCurrency !== undefined) record.valueCurrency = parsed.valueCurrency ?? null
|
|
295
332
|
if (parsed.probability !== undefined) record.probability = parsed.probability ?? null
|
|
@@ -401,6 +438,8 @@ const updateDealCommand: CommandHandler<DealUpdateInput, { dealId: string }> = {
|
|
|
401
438
|
description: before.deal.description,
|
|
402
439
|
status: before.deal.status,
|
|
403
440
|
pipelineStage: before.deal.pipelineStage,
|
|
441
|
+
pipelineId: before.deal.pipelineId,
|
|
442
|
+
pipelineStageId: before.deal.pipelineStageId,
|
|
404
443
|
valueAmount: before.deal.valueAmount,
|
|
405
444
|
valueCurrency: before.deal.valueCurrency,
|
|
406
445
|
probability: before.deal.probability,
|
|
@@ -414,6 +453,8 @@ const updateDealCommand: CommandHandler<DealUpdateInput, { dealId: string }> = {
|
|
|
414
453
|
deal.description = before.deal.description
|
|
415
454
|
deal.status = before.deal.status
|
|
416
455
|
deal.pipelineStage = before.deal.pipelineStage
|
|
456
|
+
deal.pipelineId = before.deal.pipelineId
|
|
457
|
+
deal.pipelineStageId = before.deal.pipelineStageId
|
|
417
458
|
deal.valueAmount = before.deal.valueAmount
|
|
418
459
|
deal.valueCurrency = before.deal.valueCurrency
|
|
419
460
|
deal.probability = before.deal.probability
|
|
@@ -525,6 +566,8 @@ const deleteDealCommand: CommandHandler<{ body?: Record<string, unknown>; query?
|
|
|
525
566
|
description: before.deal.description,
|
|
526
567
|
status: before.deal.status,
|
|
527
568
|
pipelineStage: before.deal.pipelineStage,
|
|
569
|
+
pipelineId: before.deal.pipelineId,
|
|
570
|
+
pipelineStageId: before.deal.pipelineStageId,
|
|
528
571
|
valueAmount: before.deal.valueAmount,
|
|
529
572
|
valueCurrency: before.deal.valueCurrency,
|
|
530
573
|
probability: before.deal.probability,
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { registerCommand } from '@open-mercato/shared/lib/commands'
|
|
2
|
+
import type { CommandHandler } from '@open-mercato/shared/lib/commands'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import { CustomerPipelineStage, CustomerDeal } from '../data/entities'
|
|
5
|
+
import {
|
|
6
|
+
pipelineStageCreateSchema,
|
|
7
|
+
pipelineStageUpdateSchema,
|
|
8
|
+
pipelineStageDeleteSchema,
|
|
9
|
+
pipelineStageReorderSchema,
|
|
10
|
+
type PipelineStageCreateInput,
|
|
11
|
+
type PipelineStageUpdateInput,
|
|
12
|
+
type PipelineStageDeleteInput,
|
|
13
|
+
type PipelineStageReorderInput,
|
|
14
|
+
} from '../data/validators'
|
|
15
|
+
import { ensureOrganizationScope, ensureTenantScope, ensureDictionaryEntry } from './shared'
|
|
16
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
17
|
+
|
|
18
|
+
const createPipelineStageCommand: CommandHandler<PipelineStageCreateInput, { stageId: string }> = {
|
|
19
|
+
id: 'customers.pipeline-stages.create',
|
|
20
|
+
async execute(rawInput, ctx) {
|
|
21
|
+
const parsed = pipelineStageCreateSchema.parse(rawInput)
|
|
22
|
+
ensureTenantScope(ctx, parsed.tenantId)
|
|
23
|
+
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
24
|
+
|
|
25
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
26
|
+
|
|
27
|
+
const existingCount = await em.count(CustomerPipelineStage, {
|
|
28
|
+
organizationId: parsed.organizationId,
|
|
29
|
+
tenantId: parsed.tenantId,
|
|
30
|
+
pipelineId: parsed.pipelineId,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const stage = em.create(CustomerPipelineStage, {
|
|
34
|
+
organizationId: parsed.organizationId,
|
|
35
|
+
tenantId: parsed.tenantId,
|
|
36
|
+
pipelineId: parsed.pipelineId,
|
|
37
|
+
label: parsed.label,
|
|
38
|
+
order: parsed.order ?? existingCount,
|
|
39
|
+
createdAt: new Date(),
|
|
40
|
+
updatedAt: new Date(),
|
|
41
|
+
})
|
|
42
|
+
em.persist(stage)
|
|
43
|
+
await em.flush()
|
|
44
|
+
|
|
45
|
+
await ensureDictionaryEntry(em, {
|
|
46
|
+
tenantId: parsed.tenantId,
|
|
47
|
+
organizationId: parsed.organizationId,
|
|
48
|
+
kind: 'pipeline_stage',
|
|
49
|
+
value: stage.label,
|
|
50
|
+
color: parsed.color,
|
|
51
|
+
icon: parsed.icon,
|
|
52
|
+
})
|
|
53
|
+
await em.flush()
|
|
54
|
+
|
|
55
|
+
return { stageId: stage.id }
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const updatePipelineStageCommand: CommandHandler<PipelineStageUpdateInput, void> = {
|
|
60
|
+
id: 'customers.pipeline-stages.update',
|
|
61
|
+
async execute(rawInput, ctx) {
|
|
62
|
+
const parsed = pipelineStageUpdateSchema.parse(rawInput)
|
|
63
|
+
|
|
64
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
65
|
+
const stage = await em.findOne(CustomerPipelineStage, { id: parsed.id })
|
|
66
|
+
if (!stage) throw new CrudHttpError(404, { error: 'Pipeline stage not found' })
|
|
67
|
+
|
|
68
|
+
ensureTenantScope(ctx, stage.tenantId)
|
|
69
|
+
ensureOrganizationScope(ctx, stage.organizationId)
|
|
70
|
+
|
|
71
|
+
if (parsed.label !== undefined) stage.label = parsed.label
|
|
72
|
+
if (parsed.order !== undefined) stage.order = parsed.order
|
|
73
|
+
stage.updatedAt = new Date()
|
|
74
|
+
|
|
75
|
+
await em.flush()
|
|
76
|
+
|
|
77
|
+
if (parsed.label !== undefined || parsed.color !== undefined || parsed.icon !== undefined) {
|
|
78
|
+
await ensureDictionaryEntry(em, {
|
|
79
|
+
tenantId: stage.tenantId,
|
|
80
|
+
organizationId: stage.organizationId,
|
|
81
|
+
kind: 'pipeline_stage',
|
|
82
|
+
value: stage.label,
|
|
83
|
+
color: parsed.color,
|
|
84
|
+
icon: parsed.icon,
|
|
85
|
+
})
|
|
86
|
+
await em.flush()
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const deletePipelineStageCommand: CommandHandler<PipelineStageDeleteInput, void> = {
|
|
92
|
+
id: 'customers.pipeline-stages.delete',
|
|
93
|
+
async execute(rawInput, ctx) {
|
|
94
|
+
const parsed = pipelineStageDeleteSchema.parse(rawInput)
|
|
95
|
+
|
|
96
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
97
|
+
const stage = await em.findOne(CustomerPipelineStage, { id: parsed.id })
|
|
98
|
+
if (!stage) throw new CrudHttpError(404, { error: 'Pipeline stage not found' })
|
|
99
|
+
|
|
100
|
+
ensureTenantScope(ctx, stage.tenantId)
|
|
101
|
+
ensureOrganizationScope(ctx, stage.organizationId)
|
|
102
|
+
|
|
103
|
+
const activeDealsCount = await em.count(CustomerDeal, {
|
|
104
|
+
pipelineStageId: parsed.id,
|
|
105
|
+
deletedAt: null,
|
|
106
|
+
})
|
|
107
|
+
if (activeDealsCount > 0) {
|
|
108
|
+
throw new CrudHttpError(409, { error: 'Cannot delete pipeline stage with active deals' })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
em.remove(stage)
|
|
112
|
+
await em.flush()
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const reorderPipelineStagesCommand: CommandHandler<PipelineStageReorderInput, void> = {
|
|
117
|
+
id: 'customers.pipeline-stages.reorder',
|
|
118
|
+
async execute(rawInput, ctx) {
|
|
119
|
+
const parsed = pipelineStageReorderSchema.parse(rawInput)
|
|
120
|
+
ensureTenantScope(ctx, parsed.tenantId)
|
|
121
|
+
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
122
|
+
|
|
123
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
124
|
+
|
|
125
|
+
const ids = parsed.stages.map((s) => s.id)
|
|
126
|
+
const stages = await em.find(CustomerPipelineStage, {
|
|
127
|
+
id: { $in: ids },
|
|
128
|
+
organizationId: parsed.organizationId,
|
|
129
|
+
tenantId: parsed.tenantId,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const stageMap = new Map<string, CustomerPipelineStage>()
|
|
133
|
+
stages.forEach((stage) => stageMap.set(stage.id, stage))
|
|
134
|
+
|
|
135
|
+
for (const { id, order } of parsed.stages) {
|
|
136
|
+
const stage = stageMap.get(id)
|
|
137
|
+
if (!stage) continue
|
|
138
|
+
stage.order = order
|
|
139
|
+
stage.updatedAt = new Date()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await em.flush()
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
registerCommand(createPipelineStageCommand)
|
|
147
|
+
registerCommand(updatePipelineStageCommand)
|
|
148
|
+
registerCommand(deletePipelineStageCommand)
|
|
149
|
+
registerCommand(reorderPipelineStagesCommand)
|
|
150
|
+
|
|
151
|
+
export {
|
|
152
|
+
createPipelineStageCommand,
|
|
153
|
+
updatePipelineStageCommand,
|
|
154
|
+
deletePipelineStageCommand,
|
|
155
|
+
reorderPipelineStagesCommand,
|
|
156
|
+
}
|