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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/generated/entities/customer_deal/index.js +4 -0
  2. package/dist/generated/entities/customer_deal/index.js.map +2 -2
  3. package/dist/generated/entities/customer_pipeline/index.js +17 -0
  4. package/dist/generated/entities/customer_pipeline/index.js.map +7 -0
  5. package/dist/generated/entities/customer_pipeline_stage/index.js +19 -0
  6. package/dist/generated/entities/customer_pipeline_stage/index.js.map +7 -0
  7. package/dist/generated/entities.ids.generated.js +2 -0
  8. package/dist/generated/entities.ids.generated.js.map +2 -2
  9. package/dist/generated/entity-fields-registry.js +4 -0
  10. package/dist/generated/entity-fields-registry.js.map +2 -2
  11. package/dist/modules/customers/acl.js +2 -0
  12. package/dist/modules/customers/acl.js.map +2 -2
  13. package/dist/modules/customers/api/deals/[id]/route.js +4 -0
  14. package/dist/modules/customers/api/deals/[id]/route.js.map +2 -2
  15. package/dist/modules/customers/api/deals/route.js +12 -0
  16. package/dist/modules/customers/api/deals/route.js.map +2 -2
  17. package/dist/modules/customers/api/dictionaries/[kind]/route.js +20 -1
  18. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  19. package/dist/modules/customers/api/pipeline-stages/reorder/route.js +69 -0
  20. package/dist/modules/customers/api/pipeline-stages/reorder/route.js.map +7 -0
  21. package/dist/modules/customers/api/pipeline-stages/route.js +275 -0
  22. package/dist/modules/customers/api/pipeline-stages/route.js.map +7 -0
  23. package/dist/modules/customers/api/pipelines/route.js +245 -0
  24. package/dist/modules/customers/api/pipelines/route.js.map +7 -0
  25. package/dist/modules/customers/backend/config/customers/page.js +2 -0
  26. package/dist/modules/customers/backend/config/customers/page.js.map +2 -2
  27. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js +439 -0
  28. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js.map +7 -0
  29. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js +17 -0
  30. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.meta.js.map +7 -0
  31. package/dist/modules/customers/backend/customers/deals/[id]/page.js +19 -1
  32. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  33. package/dist/modules/customers/backend/customers/deals/page.js +35 -1
  34. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  35. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +102 -74
  36. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  37. package/dist/modules/customers/cli.js +28 -2
  38. package/dist/modules/customers/cli.js.map +2 -2
  39. package/dist/modules/customers/commands/deals.js +34 -2
  40. package/dist/modules/customers/commands/deals.js.map +2 -2
  41. package/dist/modules/customers/commands/index.js +2 -0
  42. package/dist/modules/customers/commands/index.js.map +2 -2
  43. package/dist/modules/customers/commands/pipeline-stages.js +126 -0
  44. package/dist/modules/customers/commands/pipeline-stages.js.map +7 -0
  45. package/dist/modules/customers/commands/pipelines.js +87 -0
  46. package/dist/modules/customers/commands/pipelines.js.map +7 -0
  47. package/dist/modules/customers/components/DictionarySettings.js +0 -5
  48. package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
  49. package/dist/modules/customers/components/PipelineSettings.js +474 -0
  50. package/dist/modules/customers/components/PipelineSettings.js.map +7 -0
  51. package/dist/modules/customers/components/detail/DealForm.js +84 -12
  52. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  53. package/dist/modules/customers/data/entities.js +78 -0
  54. package/dist/modules/customers/data/entities.js.map +2 -2
  55. package/dist/modules/customers/data/validators.js +44 -0
  56. package/dist/modules/customers/data/validators.js.map +2 -2
  57. package/dist/modules/customers/migrations/Migration20260218191730.js +77 -0
  58. package/dist/modules/customers/migrations/Migration20260218191730.js.map +7 -0
  59. package/dist/modules/customers/setup.js +7 -3
  60. package/dist/modules/customers/setup.js.map +2 -2
  61. package/dist/modules/translations/api/[entityType]/[entityId]/route.js +46 -44
  62. package/dist/modules/translations/api/[entityType]/[entityId]/route.js.map +2 -2
  63. package/dist/modules/translations/api/context.js +10 -1
  64. package/dist/modules/translations/api/context.js.map +2 -2
  65. package/dist/modules/translations/commands/index.js +2 -0
  66. package/dist/modules/translations/commands/index.js.map +7 -0
  67. package/dist/modules/translations/commands/translations.js +160 -0
  68. package/dist/modules/translations/commands/translations.js.map +7 -0
  69. package/dist/modules/translations/index.js +1 -0
  70. package/dist/modules/translations/index.js.map +2 -2
  71. package/dist/modules/workflows/migrations/Migration20260222205305.js +14 -0
  72. package/dist/modules/workflows/migrations/Migration20260222205305.js.map +7 -0
  73. package/generated/entities/customer_deal/index.ts +2 -0
  74. package/generated/entities/customer_pipeline/index.ts +7 -0
  75. package/generated/entities/customer_pipeline_stage/index.ts +8 -0
  76. package/generated/entities.ids.generated.ts +2 -0
  77. package/generated/entity-fields-registry.ts +4 -0
  78. package/package.json +2 -2
  79. package/src/modules/customers/acl.ts +2 -0
  80. package/src/modules/customers/api/deals/[id]/route.ts +4 -0
  81. package/src/modules/customers/api/deals/route.ts +12 -0
  82. package/src/modules/customers/api/dictionaries/[kind]/route.ts +21 -1
  83. package/src/modules/customers/api/pipeline-stages/reorder/route.ts +71 -0
  84. package/src/modules/customers/api/pipeline-stages/route.ts +296 -0
  85. package/src/modules/customers/api/pipelines/route.ts +261 -0
  86. package/src/modules/customers/backend/config/customers/page.tsx +2 -0
  87. package/src/modules/customers/backend/config/customers/pipeline-stages/page.meta.ts +13 -0
  88. package/src/modules/customers/backend/config/customers/pipeline-stages/page.tsx +512 -0
  89. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +21 -1
  90. package/src/modules/customers/backend/customers/deals/page.tsx +33 -1
  91. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +119 -79
  92. package/src/modules/customers/cli.ts +29 -1
  93. package/src/modules/customers/commands/deals.ts +44 -1
  94. package/src/modules/customers/commands/index.ts +2 -0
  95. package/src/modules/customers/commands/pipeline-stages.ts +156 -0
  96. package/src/modules/customers/commands/pipelines.ts +105 -0
  97. package/src/modules/customers/components/DictionarySettings.tsx +0 -5
  98. package/src/modules/customers/components/PipelineSettings.tsx +570 -0
  99. package/src/modules/customers/components/detail/DealForm.tsx +89 -11
  100. package/src/modules/customers/data/entities.ts +64 -0
  101. package/src/modules/customers/data/validators.ts +57 -0
  102. package/src/modules/customers/i18n/de.json +4 -0
  103. package/src/modules/customers/i18n/en.json +4 -0
  104. package/src/modules/customers/i18n/es.json +4 -0
  105. package/src/modules/customers/i18n/pl.json +5 -1
  106. package/src/modules/customers/migrations/Migration20260218191730.ts +84 -0
  107. package/src/modules/customers/setup.ts +5 -1
  108. package/src/modules/translations/api/[entityType]/[entityId]/route.ts +65 -60
  109. package/src/modules/translations/api/context.ts +12 -0
  110. package/src/modules/translations/commands/index.ts +1 -0
  111. package/src/modules/translations/commands/translations.ts +253 -0
  112. package/src/modules/translations/index.ts +1 -0
  113. package/src/modules/workflows/migrations/Migration20260222205305.ts +13 -0
@@ -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 buildStageDefinitions(
88
- dictionary: CustomerDictionaryQueryData | undefined,
87
+ function buildStageDefinitionsFromPipelineStages(
88
+ pipelineStages: PipelineStageRecord[],
89
89
  deals: DealRecord[],
90
90
  t: ReturnType<typeof useT>,
91
91
  ): StageDefinition[] {
92
- const result: StageDefinition[] = []
93
- const seen = new Set<string>()
94
- const dictionaryEntries =
95
- dictionary?.fullEntries ?? dictionary?.entries?.map((entry) => ({ ...entry, id: entry.value })) ?? []
96
-
97
- dictionaryEntries.forEach((entry, index) => {
98
- if (!entry || typeof entry.value !== 'string') return
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 hasUnassigned = deals.some((deal) => !deal.pipelineStage)
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 groupDealsByStage(deals: DealRecord[]): Map<string | null, DealRecord[]> {
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.pipelineStage ?? null
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 dealsKey = React.useMemo(() => dealsQueryKey(scopeVersion), [scopeVersion])
201
+ const [selectedPipelineId, setSelectedPipelineId] = React.useState<string | null>(null)
223
202
 
224
- const { data: dictionaryData } = useQuery(customerDictionaryQueryOptions('pipeline-stages', scopeVersion))
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(() => groupDealsByStage(deals), [deals])
338
+ const groupedDeals = React.useMemo(() => groupDealsByStageId(deals), [deals])
321
339
  const stages = React.useMemo(
322
- () => buildStageDefinitions(dictionaryData, deals, t),
323
- [dictionaryData, deals, t],
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, pipelineStage }: { id: string; pipelineStage: string }) => {
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, pipelineStage }),
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, pipelineStage }
363
+ return { id, pipelineStageId }
346
364
  },
347
- onMutate: async ({ id, pipelineStage }) => {
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, pipelineStage } : 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.pipelineStage === stage.value) return
414
- updateStageMutation.mutate({ id: dealId, pipelineStage: stage.value })
431
+ if (deal.pipelineStageId === stage.value) return
432
+ updateStageMutation.mutate({ id: dealId, pipelineStageId: stage.value })
415
433
  },
416
- [dealMap, draggingId, t, updateStageMutation],
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
- <label className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
466
- <span>{translate('customers.deals.pipeline.sort.label', 'Sort by')}</span>
467
- <select
468
- 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"
469
- value={sortBy}
470
- onChange={handleSortChange}
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
- <option value="probability">
473
- {translate('customers.deals.pipeline.sort.probability', 'Probability (high to low)')}
474
- </option>
475
- <option value="createdAt">
476
- {translate('customers.deals.pipeline.sort.createdAt', 'Created (newest first)')}
477
- </option>
478
- <option value="expectedCloseAt">
479
- {translate('customers.deals.pipeline.sort.expectedCloseAt', 'Expected close (soonest first)')}
480
- </option>
481
- </select>
482
- </label>
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
- {dealsQuery.isLoading ? (
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
- export { seedCustomerDictionaries, seedCustomerExamples, seedCustomerStressTest, seedCurrencyDictionary }
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,
@@ -8,3 +8,5 @@ import './tags'
8
8
  import './todos'
9
9
  import './settings'
10
10
  import './dictionaries'
11
+ import './pipelines'
12
+ import './pipeline-stages'
@@ -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
+ }