@open-mercato/core 0.6.3-develop.3651.1.d3a548a5f1 → 0.6.3-develop.3660.1.53b73d5aa4
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/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/customer_settings/index.js +2 -0
- package/dist/generated/entities/customer_settings/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/customers/api/deals/aggregate/route.js +332 -0
- package/dist/modules/customers/api/deals/aggregate/route.js.map +7 -0
- package/dist/modules/customers/api/deals/bulk-update-owner/route.js +188 -0
- package/dist/modules/customers/api/deals/bulk-update-owner/route.js.map +7 -0
- package/dist/modules/customers/api/deals/bulk-update-stage/route.js +188 -0
- package/dist/modules/customers/api/deals/bulk-update-stage/route.js.map +7 -0
- package/dist/modules/customers/api/deals/route.js +255 -122
- package/dist/modules/customers/api/deals/route.js.map +2 -2
- package/dist/modules/customers/api/settings/stuck-threshold/route.js +169 -0
- package/dist/modules/customers/api/settings/stuck-threshold/route.js.map +7 -0
- package/dist/modules/customers/backend/config/customers/deals/page.js +181 -0
- package/dist/modules/customers/backend/config/customers/deals/page.js.map +7 -0
- package/dist/modules/customers/backend/config/customers/deals/page.meta.js +38 -0
- package/dist/modules/customers/backend/config/customers/deals/page.meta.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/create/page.js +17 -6
- package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/page.js +2 -0
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js +217 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js +335 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js +29 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js +160 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js +157 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js +114 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js +36 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js +199 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js +71 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js +233 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js +126 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js +31 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js +389 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js +181 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js +199 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +44 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js +70 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +278 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js +135 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js +83 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js +155 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +318 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js +138 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +126 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +71 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +2330 -415
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +3 -3
- package/dist/modules/customers/commands/deals.js +6 -0
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/commands/pipeline-stages.js +48 -13
- package/dist/modules/customers/commands/pipeline-stages.js.map +3 -3
- package/dist/modules/customers/commands/settings.js +45 -5
- package/dist/modules/customers/commands/settings.js.map +2 -2
- package/dist/modules/customers/data/enrichers.js +130 -1
- package/dist/modules/customers/data/enrichers.js.map +2 -2
- package/dist/modules/customers/data/entities.js +4 -0
- package/dist/modules/customers/data/entities.js.map +2 -2
- package/dist/modules/customers/data/validators.js +29 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/bulkDeals.js +150 -0
- package/dist/modules/customers/lib/bulkDeals.js.map +7 -0
- package/dist/modules/customers/lib/kysely.js +12 -0
- package/dist/modules/customers/lib/kysely.js.map +7 -0
- package/dist/modules/customers/lib/stuckDeals.js +32 -0
- package/dist/modules/customers/lib/stuckDeals.js.map +7 -0
- package/dist/modules/customers/migrations/Migration20260513203311_customers.js +13 -0
- package/dist/modules/customers/migrations/Migration20260513203311_customers.js.map +7 -0
- package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js +52 -0
- package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +7 -0
- package/dist/modules/customers/workers/deals-bulk-update-owner.js +41 -0
- package/dist/modules/customers/workers/deals-bulk-update-owner.js.map +7 -0
- package/dist/modules/customers/workers/deals-bulk-update-stage.js +41 -0
- package/dist/modules/customers/workers/deals-bulk-update-stage.js.map +7 -0
- package/generated/entities/customer_settings/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/modules/customers/api/deals/aggregate/route.ts +438 -0
- package/src/modules/customers/api/deals/bulk-update-owner/route.ts +216 -0
- package/src/modules/customers/api/deals/bulk-update-stage/route.ts +228 -0
- package/src/modules/customers/api/deals/route.ts +330 -135
- package/src/modules/customers/api/settings/stuck-threshold/route.ts +195 -0
- package/src/modules/customers/backend/config/customers/deals/page.meta.ts +36 -0
- package/src/modules/customers/backend/config/customers/deals/page.tsx +205 -0
- package/src/modules/customers/backend/customers/deals/create/page.tsx +25 -6
- package/src/modules/customers/backend/customers/deals/page.tsx +2 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.tsx +277 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.tsx +425 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.tsx +31 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.tsx +171 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.tsx +196 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.tsx +138 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChipButton.tsx +47 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.tsx +232 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.tsx +110 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.tsx +310 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.tsx +169 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.tsx +54 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DealCard.tsx +562 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.tsx +214 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.tsx +257 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +62 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.tsx +101 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +378 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.tsx +192 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.tsx +107 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.tsx +196 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +408 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.tsx +164 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +171 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +84 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/constants.ts +16 -0
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2841 -550
- package/src/modules/customers/commands/deals.ts +19 -0
- package/src/modules/customers/commands/pipeline-stages.ts +68 -13
- package/src/modules/customers/commands/settings.ts +64 -5
- package/src/modules/customers/data/enrichers.ts +209 -2
- package/src/modules/customers/data/entities.ts +4 -1
- package/src/modules/customers/data/validators.ts +36 -2
- package/src/modules/customers/i18n/de.json +244 -0
- package/src/modules/customers/i18n/en.json +244 -0
- package/src/modules/customers/i18n/es.json +244 -0
- package/src/modules/customers/i18n/pl.json +244 -0
- package/src/modules/customers/lib/bulkDeals.ts +231 -0
- package/src/modules/customers/lib/kysely.ts +60 -0
- package/src/modules/customers/lib/stuckDeals.ts +88 -0
- package/src/modules/customers/migrations/.snapshot-open-mercato.json +16 -0
- package/src/modules/customers/migrations/Migration20260513203311_customers.ts +13 -0
- package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +71 -0
- package/src/modules/customers/workers/deals-bulk-update-owner.ts +46 -0
- package/src/modules/customers/workers/deals-bulk-update-stage.ts +46 -0
package/src/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.tsx
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Mail, Phone, StickyNote, type LucideIcon } from 'lucide-react'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogDescription,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
} from '@open-mercato/ui/primitives/dialog'
|
|
13
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
14
|
+
import { CrudForm, type CrudField } from '@open-mercato/ui/backend/CrudForm'
|
|
15
|
+
import { createCrud } from '@open-mercato/ui/backend/utils/crud'
|
|
16
|
+
import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
|
|
17
|
+
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
18
|
+
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
19
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
20
|
+
import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
|
|
21
|
+
|
|
22
|
+
export type ActivityComposerType = 'call' | 'email' | 'note'
|
|
23
|
+
|
|
24
|
+
export type ActivityComposerContext = {
|
|
25
|
+
dealId: string
|
|
26
|
+
dealTitle: string
|
|
27
|
+
type: ActivityComposerType
|
|
28
|
+
entityId: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type ActivityComposerDialogProps = {
|
|
32
|
+
open: boolean
|
|
33
|
+
context: ActivityComposerContext | null
|
|
34
|
+
onClose: () => void
|
|
35
|
+
onCreated: () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type ActivityComposerFormValues = {
|
|
39
|
+
title: string
|
|
40
|
+
phoneNumber: string
|
|
41
|
+
body: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ACTIVITY_CONTEXT_ID = 'customers-deals-kanban:activity-composer'
|
|
45
|
+
|
|
46
|
+
const TYPE_META: Record<
|
|
47
|
+
ActivityComposerType,
|
|
48
|
+
{
|
|
49
|
+
titleKey: string
|
|
50
|
+
titleFallback: string
|
|
51
|
+
interactionType: string
|
|
52
|
+
icon: LucideIcon
|
|
53
|
+
}
|
|
54
|
+
> = {
|
|
55
|
+
call: {
|
|
56
|
+
titleKey: 'customers.deals.kanban.activityComposer.call',
|
|
57
|
+
titleFallback: 'Log a call',
|
|
58
|
+
interactionType: 'call',
|
|
59
|
+
icon: Phone,
|
|
60
|
+
},
|
|
61
|
+
email: {
|
|
62
|
+
titleKey: 'customers.deals.kanban.activityComposer.email',
|
|
63
|
+
titleFallback: 'Send email',
|
|
64
|
+
interactionType: 'email',
|
|
65
|
+
icon: Mail,
|
|
66
|
+
},
|
|
67
|
+
note: {
|
|
68
|
+
titleKey: 'customers.deals.kanban.activityComposer.note',
|
|
69
|
+
titleFallback: 'Add a note',
|
|
70
|
+
interactionType: 'note',
|
|
71
|
+
icon: StickyNote,
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function ActivityComposerDialog({
|
|
76
|
+
open,
|
|
77
|
+
context,
|
|
78
|
+
onClose,
|
|
79
|
+
onCreated,
|
|
80
|
+
}: ActivityComposerDialogProps): React.ReactElement | null {
|
|
81
|
+
const t = useT()
|
|
82
|
+
// Re-mount CrudForm whenever the dialog opens, or when the deal/type changes — these
|
|
83
|
+
// identify what we're logging activity FOR, and CrudForm's internal state must reset.
|
|
84
|
+
const [formInstanceKey, setFormInstanceKey] = React.useState(0)
|
|
85
|
+
React.useEffect(() => {
|
|
86
|
+
if (open) setFormInstanceKey((current) => current + 1)
|
|
87
|
+
}, [open])
|
|
88
|
+
|
|
89
|
+
const { runMutation, retryLastMutation } = useGuardedMutation<{
|
|
90
|
+
formId: string
|
|
91
|
+
resourceKind: string
|
|
92
|
+
resourceId: string
|
|
93
|
+
retryLastMutation: () => Promise<boolean>
|
|
94
|
+
}>({
|
|
95
|
+
contextId: ACTIVITY_CONTEXT_ID,
|
|
96
|
+
blockedMessage: translateWithFallback(t, 'ui.forms.flash.saveBlocked', 'Save blocked by validation'),
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// Email mode shows the field as "Subject" (see TYPE_META + the field config below), so
|
|
100
|
+
// the refine error must reference "subject" instead of "title" to stay coherent. Call
|
|
101
|
+
// and Note modes keep the "title or body" copy that matches their Subject-labelled field.
|
|
102
|
+
const eitherRequiredMessage = React.useMemo(
|
|
103
|
+
() =>
|
|
104
|
+
context?.type === 'email'
|
|
105
|
+
? translateWithFallback(
|
|
106
|
+
t,
|
|
107
|
+
'customers.deals.kanban.activityComposer.required.email',
|
|
108
|
+
'Add a subject or body before saving.',
|
|
109
|
+
)
|
|
110
|
+
: translateWithFallback(
|
|
111
|
+
t,
|
|
112
|
+
'customers.deals.kanban.activityComposer.required.titleOrBody',
|
|
113
|
+
'Add a title or body before saving.',
|
|
114
|
+
),
|
|
115
|
+
[context?.type, t],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const formSchema = React.useMemo(
|
|
119
|
+
() =>
|
|
120
|
+
z
|
|
121
|
+
.object({
|
|
122
|
+
title: z.string().optional(),
|
|
123
|
+
phoneNumber: z.string().optional(),
|
|
124
|
+
body: z.string().optional(),
|
|
125
|
+
})
|
|
126
|
+
// Activity must carry at least a title OR a body — purely empty saves are
|
|
127
|
+
// useless and they previously surfaced as a confusing inline error.
|
|
128
|
+
.refine(
|
|
129
|
+
(values) => (values.title?.trim().length || values.body?.trim().length) ? true : false,
|
|
130
|
+
{
|
|
131
|
+
message: eitherRequiredMessage,
|
|
132
|
+
path: ['title'],
|
|
133
|
+
},
|
|
134
|
+
),
|
|
135
|
+
[eitherRequiredMessage],
|
|
136
|
+
) as unknown as z.ZodType<ActivityComposerFormValues>
|
|
137
|
+
|
|
138
|
+
const initialValues = React.useMemo<Partial<ActivityComposerFormValues>>(
|
|
139
|
+
() => ({ title: '', phoneNumber: '', body: '' }),
|
|
140
|
+
[],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const isCall = context?.type === 'call'
|
|
144
|
+
|
|
145
|
+
const fields = React.useMemo<CrudField[]>(() => {
|
|
146
|
+
const list: CrudField[] = [
|
|
147
|
+
{
|
|
148
|
+
// CrudForm auto-focuses the first field on mount; explicit `autoFocus` is rejected
|
|
149
|
+
// by CrudBuiltinField's type and would shadow CrudForm's own focus management.
|
|
150
|
+
id: 'title',
|
|
151
|
+
label: translateWithFallback(t, 'customers.deals.kanban.activityComposer.title', 'Subject'),
|
|
152
|
+
type: 'text',
|
|
153
|
+
placeholder: translateWithFallback(
|
|
154
|
+
t,
|
|
155
|
+
'customers.deals.kanban.activityComposer.title.placeholder',
|
|
156
|
+
'Short summary (optional)',
|
|
157
|
+
),
|
|
158
|
+
},
|
|
159
|
+
]
|
|
160
|
+
if (isCall) {
|
|
161
|
+
list.push({
|
|
162
|
+
id: 'phoneNumber',
|
|
163
|
+
label: translateWithFallback(t, 'customers.deals.kanban.activityComposer.phone', 'Phone number'),
|
|
164
|
+
type: 'text',
|
|
165
|
+
placeholder: '+48 ...',
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
list.push({
|
|
169
|
+
id: 'body',
|
|
170
|
+
label: translateWithFallback(t, 'customers.deals.kanban.activityComposer.body', 'Notes'),
|
|
171
|
+
type: 'textarea',
|
|
172
|
+
rows: 4,
|
|
173
|
+
placeholder: translateWithFallback(
|
|
174
|
+
t,
|
|
175
|
+
'customers.deals.kanban.activityComposer.body.placeholder',
|
|
176
|
+
'What happened? Markdown supported.',
|
|
177
|
+
),
|
|
178
|
+
})
|
|
179
|
+
return list
|
|
180
|
+
}, [isCall, t])
|
|
181
|
+
|
|
182
|
+
const handleSubmit = React.useCallback(
|
|
183
|
+
async (values: ActivityComposerFormValues) => {
|
|
184
|
+
if (!context) return
|
|
185
|
+
const trimmedTitle = (values.title ?? '').trim()
|
|
186
|
+
const trimmedBody = (values.body ?? '').trim()
|
|
187
|
+
if (!trimmedTitle.length && !trimmedBody.length) {
|
|
188
|
+
// Zod refine already handles this, but we surface a field-level error too so the
|
|
189
|
+
// inline indicator stays anchored on the Subject input.
|
|
190
|
+
throw createCrudFormError(eitherRequiredMessage, { title: eitherRequiredMessage })
|
|
191
|
+
}
|
|
192
|
+
const meta = TYPE_META[context.type]
|
|
193
|
+
const payload: Record<string, unknown> = {
|
|
194
|
+
entityId: context.entityId,
|
|
195
|
+
interactionType: meta.interactionType,
|
|
196
|
+
dealId: context.dealId,
|
|
197
|
+
status: 'planned',
|
|
198
|
+
}
|
|
199
|
+
if (trimmedTitle.length) payload.title = trimmedTitle
|
|
200
|
+
if (trimmedBody.length) payload.body = trimmedBody
|
|
201
|
+
if (context.type === 'call' && (values.phoneNumber ?? '').trim().length) {
|
|
202
|
+
payload.phoneNumber = values.phoneNumber!.trim()
|
|
203
|
+
}
|
|
204
|
+
const operation = () =>
|
|
205
|
+
createCrud('customers/interactions', payload, {
|
|
206
|
+
errorMessage: translateWithFallback(
|
|
207
|
+
t,
|
|
208
|
+
'customers.deals.kanban.activityComposer.error',
|
|
209
|
+
'Failed to save activity.',
|
|
210
|
+
),
|
|
211
|
+
})
|
|
212
|
+
await runMutation({
|
|
213
|
+
operation,
|
|
214
|
+
context: {
|
|
215
|
+
formId: ACTIVITY_CONTEXT_ID,
|
|
216
|
+
resourceKind: 'customers.interaction',
|
|
217
|
+
resourceId: context.dealId,
|
|
218
|
+
retryLastMutation,
|
|
219
|
+
},
|
|
220
|
+
})
|
|
221
|
+
flash(
|
|
222
|
+
translateWithFallback(t, 'customers.deals.kanban.activityComposer.success', 'Activity saved.'),
|
|
223
|
+
'success',
|
|
224
|
+
)
|
|
225
|
+
onCreated()
|
|
226
|
+
onClose()
|
|
227
|
+
},
|
|
228
|
+
[context, eitherRequiredMessage, onClose, onCreated, retryLastMutation, runMutation, t],
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if (!context) return null
|
|
232
|
+
const meta = TYPE_META[context.type]
|
|
233
|
+
const Icon = meta.icon
|
|
234
|
+
const dialogTitle = translateWithFallback(t, meta.titleKey, meta.titleFallback)
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<Dialog
|
|
238
|
+
open={open}
|
|
239
|
+
onOpenChange={(next) => {
|
|
240
|
+
if (!next) onClose()
|
|
241
|
+
}}
|
|
242
|
+
>
|
|
243
|
+
<DialogContent>
|
|
244
|
+
<DialogHeader>
|
|
245
|
+
<DialogTitle className="flex items-center gap-2">
|
|
246
|
+
<Icon className="size-4" aria-hidden="true" />
|
|
247
|
+
{dialogTitle}
|
|
248
|
+
</DialogTitle>
|
|
249
|
+
<DialogDescription>
|
|
250
|
+
{translateWithFallback(
|
|
251
|
+
t,
|
|
252
|
+
'customers.deals.kanban.activityComposer.context',
|
|
253
|
+
'Deal: {title}',
|
|
254
|
+
{ title: context.dealTitle },
|
|
255
|
+
)}
|
|
256
|
+
</DialogDescription>
|
|
257
|
+
</DialogHeader>
|
|
258
|
+
|
|
259
|
+
<CrudForm<ActivityComposerFormValues>
|
|
260
|
+
key={`${context.dealId}:${context.type}:${formInstanceKey}`}
|
|
261
|
+
embedded
|
|
262
|
+
fields={fields}
|
|
263
|
+
initialValues={initialValues}
|
|
264
|
+
schema={formSchema}
|
|
265
|
+
submitLabel={translateWithFallback(
|
|
266
|
+
t,
|
|
267
|
+
'customers.deals.kanban.activityComposer.submit',
|
|
268
|
+
'Save activity',
|
|
269
|
+
)}
|
|
270
|
+
onSubmit={handleSubmit}
|
|
271
|
+
/>
|
|
272
|
+
</DialogContent>
|
|
273
|
+
</Dialog>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export default ActivityComposerDialog
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogDescription,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from '@open-mercato/ui/primitives/dialog'
|
|
12
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
13
|
+
import { CrudForm, type CrudField } from '@open-mercato/ui/backend/CrudForm'
|
|
14
|
+
import { createCrud } from '@open-mercato/ui/backend/utils/crud'
|
|
15
|
+
import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
|
|
16
|
+
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
17
|
+
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
18
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
19
|
+
import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
|
|
20
|
+
|
|
21
|
+
const ADD_STAGE_CONTEXT_ID = 'customers-deals-kanban:add-stage'
|
|
22
|
+
|
|
23
|
+
// Stage color palette — semantic tone identifiers matching Lane.tsx's ACCENT_TONE_CLASS
|
|
24
|
+
// map. We switched from arbitrary hex values to canonical tones (success / warning / info
|
|
25
|
+
// / error / neutral / brand / pink) so the picker can't silently fall through to gray
|
|
26
|
+
// when the implicit hex→tone mapping doesn't recognise a value. Empty string = no color
|
|
27
|
+
// (lane falls back to the position-based rotation). Forward-only migration 2026-05-19
|
|
28
|
+
// backfills existing hex values to their nearest tone.
|
|
29
|
+
const TONE_OPTIONS = [
|
|
30
|
+
{
|
|
31
|
+
value: '',
|
|
32
|
+
swatchClass: 'border border-dashed border-border',
|
|
33
|
+
labelKey: 'customers.deals.kanban.addStage.color.none',
|
|
34
|
+
labelFallback: 'No color',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
value: 'success',
|
|
38
|
+
swatchClass: 'bg-status-success-icon',
|
|
39
|
+
labelKey: 'customers.deals.kanban.addStage.color.success',
|
|
40
|
+
labelFallback: 'Green',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
value: 'warning',
|
|
44
|
+
swatchClass: 'bg-status-warning-icon',
|
|
45
|
+
labelKey: 'customers.deals.kanban.addStage.color.warning',
|
|
46
|
+
labelFallback: 'Amber',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
value: 'info',
|
|
50
|
+
swatchClass: 'bg-status-info-icon',
|
|
51
|
+
labelKey: 'customers.deals.kanban.addStage.color.info',
|
|
52
|
+
labelFallback: 'Blue',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
value: 'error',
|
|
56
|
+
swatchClass: 'bg-status-error-icon',
|
|
57
|
+
labelKey: 'customers.deals.kanban.addStage.color.error',
|
|
58
|
+
labelFallback: 'Red',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
value: 'neutral',
|
|
62
|
+
swatchClass: 'bg-status-neutral-icon',
|
|
63
|
+
labelKey: 'customers.deals.kanban.addStage.color.neutral',
|
|
64
|
+
labelFallback: 'Gray',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
value: 'brand',
|
|
68
|
+
swatchClass: 'bg-brand-violet',
|
|
69
|
+
labelKey: 'customers.deals.kanban.addStage.color.brand',
|
|
70
|
+
labelFallback: 'Violet',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
value: 'pink',
|
|
74
|
+
swatchClass: 'bg-status-pink-icon',
|
|
75
|
+
labelKey: 'customers.deals.kanban.addStage.color.pink',
|
|
76
|
+
labelFallback: 'Pink',
|
|
77
|
+
},
|
|
78
|
+
] as const
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Identifier values for the "Position" picker:
|
|
82
|
+
* - `end` — append after every existing stage (the default; matches the previous
|
|
83
|
+
* always-at-end behavior so the dialog stays familiar)
|
|
84
|
+
* - `start` — insert at order 0; existing stages shift right
|
|
85
|
+
* - `after:<stageId>` — insert directly after the given stage; everything to its right shifts
|
|
86
|
+
*
|
|
87
|
+
* We use a single-string discriminated value rather than a separate "mode" + "afterId"
|
|
88
|
+
* pair because CrudForm's built-in `select` field type expects scalar option values, and
|
|
89
|
+
* the backend payload computation is trivial off this single string.
|
|
90
|
+
*/
|
|
91
|
+
const POSITION_END = 'end' as const
|
|
92
|
+
const POSITION_START = 'start' as const
|
|
93
|
+
const POSITION_AFTER_PREFIX = 'after:'
|
|
94
|
+
|
|
95
|
+
export type AddStageContext = {
|
|
96
|
+
pipelineId: string
|
|
97
|
+
pipelineName: string
|
|
98
|
+
/**
|
|
99
|
+
* Snapshot of every stage already in the pipeline, ordered by their `order` field.
|
|
100
|
+
* Drives the "Position" picker. `order` is the server-side position used to build
|
|
101
|
+
* the create payload (we send `existingOrder + 1` for "after this stage").
|
|
102
|
+
*/
|
|
103
|
+
existingStages: ReadonlyArray<{ id: string; label: string; order: number }>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type AddStageDialogProps = {
|
|
107
|
+
open: boolean
|
|
108
|
+
context: AddStageContext | null
|
|
109
|
+
onClose: () => void
|
|
110
|
+
onCreated: () => void
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type AddStageFormValues = {
|
|
114
|
+
label: string
|
|
115
|
+
color: string
|
|
116
|
+
position: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve the picker's discriminated string to the integer `order` we send to the API.
|
|
121
|
+
* Returns `undefined` when the operator picked "At end" — the backend treats undefined
|
|
122
|
+
* as "append" so the legacy default behavior is preserved bit-for-bit.
|
|
123
|
+
*/
|
|
124
|
+
function resolveInsertOrder(
|
|
125
|
+
position: string,
|
|
126
|
+
existingStages: ReadonlyArray<{ id: string; label: string; order: number }>,
|
|
127
|
+
): number | undefined {
|
|
128
|
+
if (position === POSITION_END || position === '') return undefined
|
|
129
|
+
if (position === POSITION_START) return 0
|
|
130
|
+
if (position.startsWith(POSITION_AFTER_PREFIX)) {
|
|
131
|
+
const afterId = position.slice(POSITION_AFTER_PREFIX.length)
|
|
132
|
+
const idx = existingStages.findIndex((stage) => stage.id === afterId)
|
|
133
|
+
if (idx < 0) return undefined
|
|
134
|
+
// Insert position is the existing stage's order + 1; the backend will shift the
|
|
135
|
+
// remainder of the stages by +1 to keep ordering dense and unique.
|
|
136
|
+
return existingStages[idx].order + 1
|
|
137
|
+
}
|
|
138
|
+
return undefined
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function AddStageDialog({
|
|
142
|
+
open,
|
|
143
|
+
context,
|
|
144
|
+
onClose,
|
|
145
|
+
onCreated,
|
|
146
|
+
}: AddStageDialogProps): React.ReactElement | null {
|
|
147
|
+
const t = useT()
|
|
148
|
+
// Re-mount CrudForm whenever the dialog opens so cleared state is consistently fresh and
|
|
149
|
+
// any stale field errors from a previous open don't carry over.
|
|
150
|
+
const [formInstanceKey, setFormInstanceKey] = React.useState(0)
|
|
151
|
+
React.useEffect(() => {
|
|
152
|
+
if (open) setFormInstanceKey((current) => current + 1)
|
|
153
|
+
}, [open])
|
|
154
|
+
|
|
155
|
+
const { runMutation, retryLastMutation } = useGuardedMutation<{
|
|
156
|
+
formId: string
|
|
157
|
+
resourceKind: string
|
|
158
|
+
retryLastMutation: () => Promise<boolean>
|
|
159
|
+
}>({
|
|
160
|
+
contextId: ADD_STAGE_CONTEXT_ID,
|
|
161
|
+
blockedMessage: translateWithFallback(t, 'ui.forms.flash.saveBlocked', 'Save blocked by validation'),
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const labelRequiredMessage = translateWithFallback(
|
|
165
|
+
t,
|
|
166
|
+
'customers.deals.kanban.addStage.label.required',
|
|
167
|
+
'Stage name is required.',
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
// Cast widens to AddStageFormValues so CrudForm's TValues generic stays exact — Zod's
|
|
171
|
+
// inferred type is structurally identical but the generic parameters carry extra
|
|
172
|
+
// metadata that doesn't match `z.ZodType<TValues>` literally.
|
|
173
|
+
const formSchema = React.useMemo(
|
|
174
|
+
() =>
|
|
175
|
+
z.object({
|
|
176
|
+
label: z.string().trim().min(1, labelRequiredMessage),
|
|
177
|
+
color: z.string().optional(),
|
|
178
|
+
position: z.string().optional(),
|
|
179
|
+
}) as unknown as z.ZodType<AddStageFormValues>,
|
|
180
|
+
[labelRequiredMessage],
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
const positionOptions = React.useMemo(() => {
|
|
184
|
+
const existingStages = context?.existingStages ?? []
|
|
185
|
+
const opts: Array<{ value: string; label: string }> = [
|
|
186
|
+
{
|
|
187
|
+
value: POSITION_END,
|
|
188
|
+
label: translateWithFallback(
|
|
189
|
+
t,
|
|
190
|
+
'customers.deals.kanban.addStage.position.end',
|
|
191
|
+
'At the end (default)',
|
|
192
|
+
),
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
value: POSITION_START,
|
|
196
|
+
label: translateWithFallback(
|
|
197
|
+
t,
|
|
198
|
+
'customers.deals.kanban.addStage.position.start',
|
|
199
|
+
'At the start',
|
|
200
|
+
),
|
|
201
|
+
},
|
|
202
|
+
]
|
|
203
|
+
for (const stage of existingStages) {
|
|
204
|
+
opts.push({
|
|
205
|
+
value: `${POSITION_AFTER_PREFIX}${stage.id}`,
|
|
206
|
+
label: translateWithFallback(
|
|
207
|
+
t,
|
|
208
|
+
'customers.deals.kanban.addStage.position.after',
|
|
209
|
+
'After {label}',
|
|
210
|
+
{ label: stage.label },
|
|
211
|
+
),
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
return opts
|
|
215
|
+
}, [context?.existingStages, t])
|
|
216
|
+
|
|
217
|
+
const fields = React.useMemo<CrudField[]>(
|
|
218
|
+
() => [
|
|
219
|
+
{
|
|
220
|
+
// CrudForm auto-focuses the first field of the first group on mount, so we don't
|
|
221
|
+
// need an explicit `autoFocus: true` here — and adding one would be a type error
|
|
222
|
+
// because `autoFocus` is only valid on CrudCustomFieldRenderProps.
|
|
223
|
+
id: 'label',
|
|
224
|
+
label: translateWithFallback(t, 'customers.deals.kanban.addStage.label', 'Stage name'),
|
|
225
|
+
type: 'text',
|
|
226
|
+
required: true,
|
|
227
|
+
placeholder: translateWithFallback(
|
|
228
|
+
t,
|
|
229
|
+
'customers.deals.kanban.addStage.label.placeholder',
|
|
230
|
+
'e.g. Discovery',
|
|
231
|
+
),
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
// Custom inline radio-style list instead of a Radix Select — when the dialog sits
|
|
235
|
+
// near the bottom of the viewport, Radix's Select dropdown clips to the available
|
|
236
|
+
// vertical space (`--radix-select-content-available-height`), which can leave only
|
|
237
|
+
// 1–2 visible options and hide the rest behind a small scroll affordance the
|
|
238
|
+
// operator doesn't notice. Rendering the picker inline inside the dialog body
|
|
239
|
+
// gives it a deterministic max-height + clearly scrollable area, and "After {label}"
|
|
240
|
+
// entries are always visible without portal clipping.
|
|
241
|
+
id: 'position',
|
|
242
|
+
label: translateWithFallback(
|
|
243
|
+
t,
|
|
244
|
+
'customers.deals.kanban.addStage.position',
|
|
245
|
+
'Position in pipeline',
|
|
246
|
+
),
|
|
247
|
+
type: 'custom',
|
|
248
|
+
description: translateWithFallback(
|
|
249
|
+
t,
|
|
250
|
+
'customers.deals.kanban.addStage.position.help',
|
|
251
|
+
'Choose where the new stage appears on the kanban. Existing stages after the chosen position shift one step to the right.',
|
|
252
|
+
),
|
|
253
|
+
component: ({ value, setValue }) => {
|
|
254
|
+
const current = typeof value === 'string' && value.length > 0 ? value : POSITION_END
|
|
255
|
+
return (
|
|
256
|
+
<div className="flex max-h-52 flex-col gap-1 overflow-y-auto rounded-md border border-input bg-card p-1">
|
|
257
|
+
{positionOptions.map((option) => {
|
|
258
|
+
const selected = current === option.value
|
|
259
|
+
return (
|
|
260
|
+
<Button
|
|
261
|
+
variant="ghost"
|
|
262
|
+
size="sm"
|
|
263
|
+
key={option.value}
|
|
264
|
+
type="button"
|
|
265
|
+
onClick={() => setValue(option.value)}
|
|
266
|
+
aria-pressed={selected}
|
|
267
|
+
className={`flex items-center justify-between gap-2 rounded-sm px-3 py-2 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
|
|
268
|
+
selected
|
|
269
|
+
? 'bg-muted font-medium text-foreground'
|
|
270
|
+
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
|
271
|
+
}`}
|
|
272
|
+
>
|
|
273
|
+
<span className="flex-1 truncate">{option.label}</span>
|
|
274
|
+
<span
|
|
275
|
+
className={`inline-flex size-4 shrink-0 items-center justify-center rounded-full border ${
|
|
276
|
+
selected ? 'border-primary bg-primary' : 'border-input'
|
|
277
|
+
}`}
|
|
278
|
+
aria-hidden="true"
|
|
279
|
+
>
|
|
280
|
+
{selected ? (
|
|
281
|
+
<span className="size-1.5 rounded-full bg-primary-foreground" />
|
|
282
|
+
) : null}
|
|
283
|
+
</span>
|
|
284
|
+
</Button>
|
|
285
|
+
)
|
|
286
|
+
})}
|
|
287
|
+
</div>
|
|
288
|
+
)
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
// Custom render — built-in `select` can't render a swatch alongside each label.
|
|
293
|
+
// Each swatch uses the same DS class the lane bar renders, so the picker is a
|
|
294
|
+
// true preview of the kanban appearance instead of a separate hex translation.
|
|
295
|
+
id: 'color',
|
|
296
|
+
label: translateWithFallback(t, 'customers.deals.kanban.addStage.color', 'Color'),
|
|
297
|
+
type: 'custom',
|
|
298
|
+
component: ({ value, setValue }) => {
|
|
299
|
+
const current = typeof value === 'string' ? value : ''
|
|
300
|
+
return (
|
|
301
|
+
<div className="flex flex-wrap gap-2">
|
|
302
|
+
{TONE_OPTIONS.map((option) => {
|
|
303
|
+
const selected = current === option.value
|
|
304
|
+
const label = translateWithFallback(t, option.labelKey, option.labelFallback)
|
|
305
|
+
return (
|
|
306
|
+
<Button
|
|
307
|
+
variant="outline"
|
|
308
|
+
size="sm"
|
|
309
|
+
key={option.value || 'none'}
|
|
310
|
+
type="button"
|
|
311
|
+
onClick={() => setValue(option.value)}
|
|
312
|
+
aria-pressed={selected}
|
|
313
|
+
title={label}
|
|
314
|
+
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
|
|
315
|
+
selected
|
|
316
|
+
? 'border-primary bg-muted text-foreground'
|
|
317
|
+
: 'border-input bg-card text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
318
|
+
}`}
|
|
319
|
+
>
|
|
320
|
+
<span
|
|
321
|
+
className={`size-3 rounded-sm ${option.swatchClass}`}
|
|
322
|
+
aria-hidden="true"
|
|
323
|
+
/>
|
|
324
|
+
<span>{label}</span>
|
|
325
|
+
</Button>
|
|
326
|
+
)
|
|
327
|
+
})}
|
|
328
|
+
</div>
|
|
329
|
+
)
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
],
|
|
333
|
+
[positionOptions, t],
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
const initialValues = React.useMemo<Partial<AddStageFormValues>>(
|
|
337
|
+
() => ({ label: '', color: '', position: POSITION_END }),
|
|
338
|
+
[],
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
const handleSubmit = React.useCallback(
|
|
342
|
+
async (values: AddStageFormValues) => {
|
|
343
|
+
if (!context) return
|
|
344
|
+
const trimmedLabel = values.label.trim()
|
|
345
|
+
if (!trimmedLabel.length) {
|
|
346
|
+
// Zod schema should already catch this — but we re-verify defensively because the
|
|
347
|
+
// Zod check uses `trim().min(1)` and the field renders trimmed values consistently.
|
|
348
|
+
throw createCrudFormError(labelRequiredMessage, { label: labelRequiredMessage })
|
|
349
|
+
}
|
|
350
|
+
const payload: Record<string, unknown> = {
|
|
351
|
+
pipelineId: context.pipelineId,
|
|
352
|
+
label: trimmedLabel,
|
|
353
|
+
}
|
|
354
|
+
if (values.color) payload.color = values.color
|
|
355
|
+
const insertOrder = resolveInsertOrder(values.position ?? POSITION_END, context.existingStages)
|
|
356
|
+
// Only send `order` when the operator explicitly chose a non-default position. Letting
|
|
357
|
+
// it stay undefined preserves the legacy "append to end" behavior on the backend
|
|
358
|
+
// (and keeps payloads clean / debugger-friendly for typical add flows).
|
|
359
|
+
if (insertOrder !== undefined) payload.order = insertOrder
|
|
360
|
+
const operation = () =>
|
|
361
|
+
createCrud('customers/pipeline-stages', payload, {
|
|
362
|
+
errorMessage: translateWithFallback(
|
|
363
|
+
t,
|
|
364
|
+
'customers.deals.kanban.addStage.error',
|
|
365
|
+
'Failed to add stage.',
|
|
366
|
+
),
|
|
367
|
+
})
|
|
368
|
+
await runMutation({
|
|
369
|
+
operation,
|
|
370
|
+
context: {
|
|
371
|
+
formId: ADD_STAGE_CONTEXT_ID,
|
|
372
|
+
resourceKind: 'customers.pipeline_stage',
|
|
373
|
+
retryLastMutation,
|
|
374
|
+
},
|
|
375
|
+
})
|
|
376
|
+
flash(translateWithFallback(t, 'customers.deals.kanban.addStage.success', 'Stage added.'), 'success')
|
|
377
|
+
onCreated()
|
|
378
|
+
onClose()
|
|
379
|
+
},
|
|
380
|
+
[context, labelRequiredMessage, onClose, onCreated, retryLastMutation, runMutation, t],
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if (!context) return null
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<Dialog
|
|
387
|
+
open={open}
|
|
388
|
+
onOpenChange={(next) => {
|
|
389
|
+
if (!next) onClose()
|
|
390
|
+
}}
|
|
391
|
+
>
|
|
392
|
+
<DialogContent className="sm:max-w-md">
|
|
393
|
+
<DialogHeader>
|
|
394
|
+
<DialogTitle>
|
|
395
|
+
{translateWithFallback(t, 'customers.deals.kanban.addStage.title', 'New stage')}
|
|
396
|
+
</DialogTitle>
|
|
397
|
+
<DialogDescription>
|
|
398
|
+
{translateWithFallback(
|
|
399
|
+
t,
|
|
400
|
+
'customers.deals.kanban.addStage.context',
|
|
401
|
+
'Pipeline: {pipeline}',
|
|
402
|
+
{ pipeline: context.pipelineName },
|
|
403
|
+
)}
|
|
404
|
+
</DialogDescription>
|
|
405
|
+
</DialogHeader>
|
|
406
|
+
|
|
407
|
+
<CrudForm<AddStageFormValues>
|
|
408
|
+
key={`${context.pipelineId}:${formInstanceKey}`}
|
|
409
|
+
embedded
|
|
410
|
+
fields={fields}
|
|
411
|
+
initialValues={initialValues}
|
|
412
|
+
schema={formSchema}
|
|
413
|
+
submitLabel={translateWithFallback(
|
|
414
|
+
t,
|
|
415
|
+
'customers.deals.kanban.addStage.submit',
|
|
416
|
+
'Add stage',
|
|
417
|
+
)}
|
|
418
|
+
onSubmit={handleSubmit}
|
|
419
|
+
/>
|
|
420
|
+
</DialogContent>
|
|
421
|
+
</Dialog>
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export default AddStageDialog
|