@open-mercato/core 0.6.3-develop.3894.1.352abf4240 → 0.6.3
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/global.d.js +1 -0
- package/dist/global.d.js.map +7 -0
- package/dist/modules/catalog/commands/variants.js +11 -5
- package/dist/modules/catalog/commands/variants.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/create/page.js +3 -61
- package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
- package/dist/modules/customers/components/detail/DealForm.js +2 -0
- package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
- package/dist/modules/customers/components/detail/create/CreateDealForm.js +233 -0
- package/dist/modules/customers/components/detail/create/CreateDealForm.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealAssociationsField.js +209 -0
- package/dist/modules/customers/components/detail/create/DealAssociationsField.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealAssociationsSection.js +67 -0
- package/dist/modules/customers/components/detail/create/DealAssociationsSection.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealCreateSidebar.js +73 -0
- package/dist/modules/customers/components/detail/create/DealCreateSidebar.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealCurrencyField.js +92 -0
- package/dist/modules/customers/components/detail/create/DealCurrencyField.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealCustomAttributes.js +81 -0
- package/dist/modules/customers/components/detail/create/DealCustomAttributes.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealDetailsFields.js +171 -0
- package/dist/modules/customers/components/detail/create/DealDetailsFields.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealFormField.js +24 -0
- package/dist/modules/customers/components/detail/create/DealFormField.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealSectionCard.js +29 -0
- package/dist/modules/customers/components/detail/create/DealSectionCard.js.map +7 -0
- package/dist/modules/customers/components/detail/create/DealTipsCard.js +19 -0
- package/dist/modules/customers/components/detail/create/DealTipsCard.js.map +7 -0
- package/dist/modules/customers/components/detail/create/PipelineSelect.js +41 -0
- package/dist/modules/customers/components/detail/create/PipelineSelect.js.map +7 -0
- package/dist/modules/customers/components/detail/create/PipelineStageSelect.js +49 -0
- package/dist/modules/customers/components/detail/create/PipelineStageSelect.js.map +7 -0
- package/dist/modules/customers/components/detail/create/SuffixInput.js +21 -0
- package/dist/modules/customers/components/detail/create/SuffixInput.js.map +7 -0
- package/dist/modules/customers/components/detail/create/dealCustomFieldControl.js +270 -0
- package/dist/modules/customers/components/detail/create/dealCustomFieldControl.js.map +7 -0
- package/dist/modules/customers/components/detail/create/dealFormTypes.js +17 -0
- package/dist/modules/customers/components/detail/create/dealFormTypes.js.map +7 -0
- package/dist/modules/customers/components/detail/create/dealNumericInput.js +16 -0
- package/dist/modules/customers/components/detail/create/dealNumericInput.js.map +7 -0
- package/dist/modules/customers/components/detail/create/useDealCustomFields.js +93 -0
- package/dist/modules/customers/components/detail/create/useDealCustomFields.js.map +7 -0
- package/dist/modules/customers/components/detail/create/useDealPipelines.js +59 -0
- package/dist/modules/customers/components/detail/create/useDealPipelines.js.map +7 -0
- package/dist/modules/customers/components/formConfig.js +4 -2
- package/dist/modules/customers/components/formConfig.js.map +2 -2
- package/dist/modules/dictionaries/components/DictionaryEntrySelect.js +5 -2
- package/dist/modules/dictionaries/components/DictionaryEntrySelect.js.map +2 -2
- package/dist/modules/feature_toggles/lib/feature-flag-check.js +13 -5
- package/dist/modules/feature_toggles/lib/feature-flag-check.js.map +2 -2
- package/dist/modules/query_index/subscribers/coverage_refresh.js +6 -1
- package/dist/modules/query_index/subscribers/coverage_refresh.js.map +2 -2
- package/dist/modules/sync_excel/widgets/injection/upload-config/target-options.js +33 -5
- package/dist/modules/sync_excel/widgets/injection/upload-config/target-options.js.map +2 -2
- package/dist/modules/workflows/components/WorkflowGraph.js +29 -186
- package/dist/modules/workflows/components/WorkflowGraph.js.map +2 -2
- package/dist/modules/workflows/components/WorkflowGraphImpl.js +196 -0
- package/dist/modules/workflows/components/WorkflowGraphImpl.js.map +7 -0
- package/package.json +8 -9
- package/src/global.d.ts +9 -0
- package/src/modules/catalog/commands/variants.ts +14 -5
- package/src/modules/customers/backend/customers/deals/create/page.tsx +3 -64
- package/src/modules/customers/components/detail/DealForm.tsx +2 -0
- package/src/modules/customers/components/detail/create/CreateDealForm.tsx +254 -0
- package/src/modules/customers/components/detail/create/DealAssociationsField.tsx +253 -0
- package/src/modules/customers/components/detail/create/DealAssociationsSection.tsx +72 -0
- package/src/modules/customers/components/detail/create/DealCreateSidebar.tsx +79 -0
- package/src/modules/customers/components/detail/create/DealCurrencyField.tsx +108 -0
- package/src/modules/customers/components/detail/create/DealCustomAttributes.tsx +118 -0
- package/src/modules/customers/components/detail/create/DealDetailsFields.tsx +171 -0
- package/src/modules/customers/components/detail/create/DealFormField.tsx +39 -0
- package/src/modules/customers/components/detail/create/DealSectionCard.tsx +40 -0
- package/src/modules/customers/components/detail/create/DealTipsCard.tsx +26 -0
- package/src/modules/customers/components/detail/create/PipelineSelect.tsx +55 -0
- package/src/modules/customers/components/detail/create/PipelineStageSelect.tsx +70 -0
- package/src/modules/customers/components/detail/create/SuffixInput.tsx +20 -0
- package/src/modules/customers/components/detail/create/dealCustomFieldControl.tsx +310 -0
- package/src/modules/customers/components/detail/create/dealFormTypes.ts +29 -0
- package/src/modules/customers/components/detail/create/dealNumericInput.ts +20 -0
- package/src/modules/customers/components/detail/create/useDealCustomFields.ts +118 -0
- package/src/modules/customers/components/detail/create/useDealPipelines.ts +80 -0
- package/src/modules/customers/components/formConfig.tsx +3 -0
- package/src/modules/customers/i18n/de.json +26 -0
- package/src/modules/customers/i18n/en.json +26 -0
- package/src/modules/customers/i18n/es.json +26 -0
- package/src/modules/customers/i18n/pl.json +26 -0
- package/src/modules/dictionaries/components/DictionaryEntrySelect.tsx +12 -1
- package/src/modules/feature_toggles/lib/feature-flag-check.ts +14 -4
- package/src/modules/query_index/subscribers/coverage_refresh.ts +7 -1
- package/src/modules/resources/i18n/de.json +1 -0
- package/src/modules/resources/i18n/en.json +1 -0
- package/src/modules/resources/i18n/es.json +1 -0
- package/src/modules/resources/i18n/pl.json +1 -0
- package/src/modules/sales/i18n/de.json +2 -0
- package/src/modules/sales/i18n/en.json +2 -0
- package/src/modules/sales/i18n/es.json +2 -0
- package/src/modules/sales/i18n/pl.json +2 -0
- package/src/modules/sync_excel/widgets/injection/upload-config/target-options.ts +40 -5
- package/src/modules/workflows/components/WorkflowGraph.tsx +39 -235
- package/src/modules/workflows/components/WorkflowGraphImpl.tsx +233 -0
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import {
|
|
4
|
+
import { useSearchParams } from 'next/navigation'
|
|
5
5
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
6
|
-
import {
|
|
7
|
-
import { createCrud } from '@open-mercato/ui/backend/utils/crud'
|
|
8
|
-
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
9
|
-
import { DealForm, type DealFormSubmitPayload } from '../../../../components/detail/DealForm'
|
|
10
|
-
import { useCurrencyDictionary } from '../../../../components/detail/hooks/useCurrencyDictionary'
|
|
6
|
+
import { CreateDealForm } from '../../../../components/detail/create/CreateDealForm'
|
|
11
7
|
|
|
12
8
|
const DEFAULT_RETURN_TO = '/backend/customers/deals'
|
|
13
9
|
|
|
@@ -24,73 +20,16 @@ function resolveReturnTo(value: string | null | undefined): string {
|
|
|
24
20
|
}
|
|
25
21
|
|
|
26
22
|
export default function CreateDealPage() {
|
|
27
|
-
const t = useT()
|
|
28
|
-
const router = useRouter()
|
|
29
23
|
const searchParams = useSearchParams()
|
|
30
24
|
const returnTo = React.useMemo(
|
|
31
25
|
() => resolveReturnTo(searchParams?.get('returnTo') ?? null),
|
|
32
26
|
[searchParams],
|
|
33
27
|
)
|
|
34
|
-
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
|
35
|
-
useCurrencyDictionary()
|
|
36
|
-
|
|
37
|
-
const handleCancel = React.useCallback(() => {
|
|
38
|
-
router.push(returnTo)
|
|
39
|
-
}, [router, returnTo])
|
|
40
|
-
|
|
41
|
-
const handleSubmit = React.useCallback(
|
|
42
|
-
async ({ base, custom }: DealFormSubmitPayload) => {
|
|
43
|
-
if (isSubmitting) return
|
|
44
|
-
setIsSubmitting(true)
|
|
45
|
-
try {
|
|
46
|
-
const payload: Record<string, unknown> = {
|
|
47
|
-
title: base.title,
|
|
48
|
-
status: base.status ?? undefined,
|
|
49
|
-
pipelineStage: base.pipelineStage ?? undefined,
|
|
50
|
-
pipelineId: base.pipelineId ?? undefined,
|
|
51
|
-
pipelineStageId: base.pipelineStageId ?? undefined,
|
|
52
|
-
valueAmount: typeof base.valueAmount === 'number' ? base.valueAmount : undefined,
|
|
53
|
-
valueCurrency: base.valueCurrency ?? undefined,
|
|
54
|
-
probability: typeof base.probability === 'number' ? base.probability : undefined,
|
|
55
|
-
expectedCloseAt: base.expectedCloseAt ?? undefined,
|
|
56
|
-
description: base.description ?? undefined,
|
|
57
|
-
personIds: Array.isArray(base.personIds) && base.personIds.length ? base.personIds : undefined,
|
|
58
|
-
companyIds: Array.isArray(base.companyIds) && base.companyIds.length ? base.companyIds : undefined,
|
|
59
|
-
}
|
|
60
|
-
if (Object.keys(custom).length) payload.customFields = custom
|
|
61
|
-
|
|
62
|
-
await createCrud('customers/deals', payload, {
|
|
63
|
-
errorMessage: t('customers.deals.create.error', 'Failed to create deal.'),
|
|
64
|
-
})
|
|
65
|
-
flash(t('customers.people.detail.deals.success', 'Deal created.'), 'success')
|
|
66
|
-
router.push(returnTo)
|
|
67
|
-
} catch (err) {
|
|
68
|
-
const message =
|
|
69
|
-
err instanceof Error
|
|
70
|
-
? err.message
|
|
71
|
-
: t('customers.deals.create.error', 'Failed to create deal.')
|
|
72
|
-
flash(message, 'error')
|
|
73
|
-
throw err instanceof Error ? err : new Error(message)
|
|
74
|
-
} finally {
|
|
75
|
-
setIsSubmitting(false)
|
|
76
|
-
}
|
|
77
|
-
},
|
|
78
|
-
[isSubmitting, returnTo, router, t],
|
|
79
|
-
)
|
|
80
28
|
|
|
81
29
|
return (
|
|
82
30
|
<Page>
|
|
83
31
|
<PageBody>
|
|
84
|
-
<
|
|
85
|
-
mode="create"
|
|
86
|
-
onSubmit={handleSubmit}
|
|
87
|
-
onCancel={handleCancel}
|
|
88
|
-
isSubmitting={isSubmitting}
|
|
89
|
-
submitLabel={t('customers.deals.create.submit', 'Create deal')}
|
|
90
|
-
embedded={false}
|
|
91
|
-
title={t('customers.deals.create.title', 'Create deal')}
|
|
92
|
-
backHref={returnTo}
|
|
93
|
-
/>
|
|
32
|
+
<CreateDealForm returnTo={returnTo} />
|
|
94
33
|
</PageBody>
|
|
95
34
|
</Page>
|
|
96
35
|
)
|
|
@@ -177,6 +177,8 @@ const schema = z.object({
|
|
|
177
177
|
companyIds: z.array(z.string().trim().min(1)).optional(),
|
|
178
178
|
}).passthrough()
|
|
179
179
|
|
|
180
|
+
export const dealFormSchema = schema
|
|
181
|
+
|
|
180
182
|
import { toDateInputValue as toDateInputValueOrNull } from '@open-mercato/shared/lib/date/format'
|
|
181
183
|
|
|
182
184
|
function toDateInputValue(value: string | null | undefined): string {
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { Briefcase, Save } from 'lucide-react'
|
|
6
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
|
|
8
|
+
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
9
|
+
import { createCrud } from '@open-mercato/ui/backend/utils/crud'
|
|
10
|
+
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
11
|
+
import { FormHeader } from '@open-mercato/ui/backend/forms'
|
|
12
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
13
|
+
import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
14
|
+
import { dealFormSchema } from '../DealForm'
|
|
15
|
+
import { createDictionarySelectLabels } from '../utils'
|
|
16
|
+
import { DealSectionCard } from './DealSectionCard'
|
|
17
|
+
import { DealDetailsFields } from './DealDetailsFields'
|
|
18
|
+
import { DealAssociationsSection } from './DealAssociationsSection'
|
|
19
|
+
import { DealCreateSidebar } from './DealCreateSidebar'
|
|
20
|
+
import { useDealPipelines } from './useDealPipelines'
|
|
21
|
+
import { useDealCustomFields } from './useDealCustomFields'
|
|
22
|
+
import { EMPTY_VALUES, type BaseValues } from './dealFormTypes'
|
|
23
|
+
|
|
24
|
+
const CONTEXT_ID = 'customers.deals.create'
|
|
25
|
+
const DEAL_ENTITY_ID = 'customers:customer_deal'
|
|
26
|
+
const CUSTOM_FIELDS_MANAGE_HREF = `/backend/entities/system/${encodeURIComponent(DEAL_ENTITY_ID)}`
|
|
27
|
+
|
|
28
|
+
export type CreateDealFormProps = {
|
|
29
|
+
returnTo: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function CreateDealForm({ returnTo }: CreateDealFormProps) {
|
|
33
|
+
const t = useT()
|
|
34
|
+
const router = useRouter()
|
|
35
|
+
const tr = React.useCallback(
|
|
36
|
+
(key: string, fallback: string, params?: Record<string, string | number>) =>
|
|
37
|
+
translateWithFallback(t, key, fallback, params),
|
|
38
|
+
[t],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const [values, setValues] = React.useState<BaseValues>(EMPTY_VALUES)
|
|
42
|
+
const [errors, setErrors] = React.useState<Record<string, string>>({})
|
|
43
|
+
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
|
44
|
+
|
|
45
|
+
const { pipelines, stages, loadStages } = useDealPipelines()
|
|
46
|
+
const {
|
|
47
|
+
customValues,
|
|
48
|
+
customFieldsLoaded,
|
|
49
|
+
customCount,
|
|
50
|
+
handleCustomChange,
|
|
51
|
+
handleCustomAttributesLoaded,
|
|
52
|
+
validateCustomFields,
|
|
53
|
+
collectNormalizedCustomValues,
|
|
54
|
+
} = useDealCustomFields(tr)
|
|
55
|
+
|
|
56
|
+
const { runMutation, retryLastMutation } = useGuardedMutation<{
|
|
57
|
+
formId: string
|
|
58
|
+
resourceKind: string
|
|
59
|
+
retryLastMutation: () => Promise<boolean>
|
|
60
|
+
}>({
|
|
61
|
+
contextId: CONTEXT_ID,
|
|
62
|
+
blockedMessage: tr('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const statusLabels = React.useMemo(
|
|
66
|
+
() => createDictionarySelectLabels('deal-statuses', (key, fallback) => tr(key, fallback ?? key)),
|
|
67
|
+
[tr],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const patch = React.useCallback((partial: Partial<BaseValues>) => {
|
|
71
|
+
setValues((current) => ({ ...current, ...partial }))
|
|
72
|
+
}, [])
|
|
73
|
+
|
|
74
|
+
const handlePipelineChange = React.useCallback(
|
|
75
|
+
(id: string) => {
|
|
76
|
+
patch({ pipelineId: id, pipelineStageId: '' })
|
|
77
|
+
// loadStages resets stages to [] on failure; the rejection is intentionally ignored here.
|
|
78
|
+
loadStages(id).catch(() => {})
|
|
79
|
+
},
|
|
80
|
+
[loadStages, patch],
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const handleCancel = React.useCallback(() => {
|
|
84
|
+
router.push(returnTo)
|
|
85
|
+
}, [returnTo, router])
|
|
86
|
+
|
|
87
|
+
const handleSubmit = React.useCallback(async () => {
|
|
88
|
+
if (isSubmitting) return
|
|
89
|
+
if (!customFieldsLoaded) {
|
|
90
|
+
flash(tr('customers.deals.create.sections.custom.loading', 'Loading custom fields...'), 'error')
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
const merged = { ...values, ...customValues }
|
|
94
|
+
const parsed = dealFormSchema.safeParse(merged)
|
|
95
|
+
if (!parsed.success) {
|
|
96
|
+
const fieldErrors: Record<string, string> = {}
|
|
97
|
+
for (const issue of parsed.error.issues) {
|
|
98
|
+
const key = typeof issue.path[0] === 'string' ? issue.path[0] : undefined
|
|
99
|
+
if (key && !fieldErrors[key]) fieldErrors[key] = tr(issue.message, issue.message)
|
|
100
|
+
}
|
|
101
|
+
setErrors(fieldErrors)
|
|
102
|
+
const firstMessage = Object.values(fieldErrors)[0]
|
|
103
|
+
if (firstMessage) flash(firstMessage, 'error')
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const customFieldErrors = validateCustomFields(merged)
|
|
108
|
+
if (Object.keys(customFieldErrors).length) {
|
|
109
|
+
setErrors(customFieldErrors)
|
|
110
|
+
const firstMessage = Object.values(customFieldErrors)[0]
|
|
111
|
+
if (firstMessage) flash(firstMessage, 'error')
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
setErrors({})
|
|
116
|
+
setIsSubmitting(true)
|
|
117
|
+
try {
|
|
118
|
+
const data = parsed.data
|
|
119
|
+
const expectedCloseAt =
|
|
120
|
+
data.expectedCloseAt && data.expectedCloseAt.length
|
|
121
|
+
? new Date(data.expectedCloseAt).toISOString()
|
|
122
|
+
: undefined
|
|
123
|
+
const payload: Record<string, unknown> = {
|
|
124
|
+
title: data.title,
|
|
125
|
+
status: data.status || undefined,
|
|
126
|
+
pipelineId: data.pipelineId || undefined,
|
|
127
|
+
pipelineStageId: data.pipelineStageId || undefined,
|
|
128
|
+
valueAmount: typeof data.valueAmount === 'number' ? data.valueAmount : undefined,
|
|
129
|
+
valueCurrency: data.valueCurrency || undefined,
|
|
130
|
+
probability: typeof data.probability === 'number' ? data.probability : undefined,
|
|
131
|
+
expectedCloseAt,
|
|
132
|
+
description: data.description && data.description.length ? data.description : undefined,
|
|
133
|
+
personIds: values.personIds.length ? values.personIds : undefined,
|
|
134
|
+
companyIds: values.companyIds.length ? values.companyIds : undefined,
|
|
135
|
+
}
|
|
136
|
+
const custom = collectNormalizedCustomValues(merged)
|
|
137
|
+
if (Object.keys(custom).length) payload.customFields = custom
|
|
138
|
+
|
|
139
|
+
await runMutation({
|
|
140
|
+
operation: () =>
|
|
141
|
+
createCrud('customers/deals', payload, {
|
|
142
|
+
errorMessage: tr('customers.deals.create.error', 'Failed to create deal.'),
|
|
143
|
+
}),
|
|
144
|
+
context: { formId: CONTEXT_ID, resourceKind: 'customers.deal', retryLastMutation },
|
|
145
|
+
mutationPayload: payload,
|
|
146
|
+
})
|
|
147
|
+
flash(tr('customers.people.detail.deals.success', 'Deal created.'), 'success')
|
|
148
|
+
router.push(returnTo)
|
|
149
|
+
} catch (err) {
|
|
150
|
+
const message = err instanceof Error ? err.message : tr('customers.deals.create.error', 'Failed to create deal.')
|
|
151
|
+
flash(message, 'error')
|
|
152
|
+
} finally {
|
|
153
|
+
setIsSubmitting(false)
|
|
154
|
+
}
|
|
155
|
+
}, [
|
|
156
|
+
collectNormalizedCustomValues,
|
|
157
|
+
customFieldsLoaded,
|
|
158
|
+
customValues,
|
|
159
|
+
isSubmitting,
|
|
160
|
+
retryLastMutation,
|
|
161
|
+
returnTo,
|
|
162
|
+
router,
|
|
163
|
+
runMutation,
|
|
164
|
+
tr,
|
|
165
|
+
validateCustomFields,
|
|
166
|
+
values,
|
|
167
|
+
])
|
|
168
|
+
|
|
169
|
+
const onKeyDown = React.useCallback(
|
|
170
|
+
(event: React.KeyboardEvent<HTMLFormElement>) => {
|
|
171
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
172
|
+
event.preventDefault()
|
|
173
|
+
handleSubmit()
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
[handleSubmit],
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
const onFormSubmit = React.useCallback(
|
|
180
|
+
(event: React.FormEvent<HTMLFormElement>) => {
|
|
181
|
+
event.preventDefault()
|
|
182
|
+
handleSubmit()
|
|
183
|
+
},
|
|
184
|
+
[handleSubmit],
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
const cancelLabel = tr('customers.deals.create.cancel', 'Cancel')
|
|
188
|
+
const submitLabel = tr('customers.deals.create.submit', 'Create deal')
|
|
189
|
+
const submitDisabled = !customFieldsLoaded
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<form className="mx-auto max-w-screen-2xl" onKeyDown={onKeyDown} onSubmit={onFormSubmit} noValidate>
|
|
193
|
+
<FormHeader
|
|
194
|
+
backHref={returnTo}
|
|
195
|
+
backLabel={tr('customers.deals.create.back', 'Back to deals')}
|
|
196
|
+
/>
|
|
197
|
+
|
|
198
|
+
<div className="mt-6 grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_330px]">
|
|
199
|
+
<div className="space-y-4">
|
|
200
|
+
<DealSectionCard
|
|
201
|
+
icon={Briefcase}
|
|
202
|
+
title={tr('customers.deals.create.title', 'Create deal')}
|
|
203
|
+
subtitle={tr('customers.deals.create.sections.details.subtitle', 'Core opportunity info')}
|
|
204
|
+
actions={
|
|
205
|
+
<>
|
|
206
|
+
<Button type="button" variant="outline" onClick={handleCancel} disabled={isSubmitting}>
|
|
207
|
+
{cancelLabel}
|
|
208
|
+
</Button>
|
|
209
|
+
<Button type="button" onClick={handleSubmit} disabled={isSubmitting || submitDisabled}>
|
|
210
|
+
{isSubmitting ? <Spinner className="size-4" /> : <Save className="size-4" />}
|
|
211
|
+
{submitLabel}
|
|
212
|
+
</Button>
|
|
213
|
+
</>
|
|
214
|
+
}
|
|
215
|
+
>
|
|
216
|
+
<DealDetailsFields
|
|
217
|
+
values={values}
|
|
218
|
+
errors={errors}
|
|
219
|
+
isSubmitting={isSubmitting}
|
|
220
|
+
patch={patch}
|
|
221
|
+
onPipelineChange={handlePipelineChange}
|
|
222
|
+
pipelines={pipelines}
|
|
223
|
+
stages={stages}
|
|
224
|
+
statusLabels={statusLabels}
|
|
225
|
+
tr={tr}
|
|
226
|
+
/>
|
|
227
|
+
</DealSectionCard>
|
|
228
|
+
|
|
229
|
+
<DealAssociationsSection
|
|
230
|
+
tr={tr}
|
|
231
|
+
personIds={values.personIds}
|
|
232
|
+
companyIds={values.companyIds}
|
|
233
|
+
onPeopleChange={(next) => patch({ personIds: next })}
|
|
234
|
+
onCompaniesChange={(next) => patch({ companyIds: next })}
|
|
235
|
+
disabled={isSubmitting}
|
|
236
|
+
/>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<DealCreateSidebar
|
|
240
|
+
tr={tr}
|
|
241
|
+
customValues={customValues}
|
|
242
|
+
onCustomChange={handleCustomChange}
|
|
243
|
+
errors={errors}
|
|
244
|
+
disabled={isSubmitting}
|
|
245
|
+
customCount={customCount}
|
|
246
|
+
manageHref={CUSTOM_FIELDS_MANAGE_HREF}
|
|
247
|
+
onCustomLoaded={handleCustomAttributesLoaded}
|
|
248
|
+
/>
|
|
249
|
+
</div>
|
|
250
|
+
</form>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export default CreateDealForm
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Building2, X } from 'lucide-react'
|
|
5
|
+
import { Avatar } from '@open-mercato/ui/primitives/avatar'
|
|
6
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
7
|
+
import { IconButton } from '@open-mercato/ui/primitives/icon-button'
|
|
8
|
+
import { SearchInput } from '@open-mercato/ui/primitives/search-input'
|
|
9
|
+
import { useDealAssociationLookups } from '../DealForm'
|
|
10
|
+
|
|
11
|
+
type AssociationOption = {
|
|
12
|
+
id: string
|
|
13
|
+
label: string
|
|
14
|
+
subtitle?: string | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type DealAssociationsFieldProps = {
|
|
18
|
+
id?: string
|
|
19
|
+
kind: 'people' | 'companies'
|
|
20
|
+
value: string[]
|
|
21
|
+
onChange: (next: string[]) => void
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
labels: {
|
|
24
|
+
placeholder: string
|
|
25
|
+
empty: string
|
|
26
|
+
loading: string
|
|
27
|
+
noResults: string
|
|
28
|
+
remove: string
|
|
29
|
+
error: string
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sanitizeIdList(input: unknown): string[] {
|
|
34
|
+
if (!Array.isArray(input)) return []
|
|
35
|
+
const set = new Set<string>()
|
|
36
|
+
input.forEach((candidate) => {
|
|
37
|
+
if (typeof candidate !== 'string') return
|
|
38
|
+
const trimmed = candidate.trim()
|
|
39
|
+
if (!trimmed.length) return
|
|
40
|
+
set.add(trimmed)
|
|
41
|
+
})
|
|
42
|
+
return Array.from(set)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function DealAssociationsField({
|
|
46
|
+
id,
|
|
47
|
+
kind,
|
|
48
|
+
value,
|
|
49
|
+
onChange,
|
|
50
|
+
disabled = false,
|
|
51
|
+
labels,
|
|
52
|
+
}: DealAssociationsFieldProps) {
|
|
53
|
+
const lookups = useDealAssociationLookups()
|
|
54
|
+
const search = kind === 'people' ? lookups.searchPeople : lookups.searchCompanies
|
|
55
|
+
const fetchByIds = kind === 'people' ? lookups.fetchPeopleByIds : lookups.fetchCompaniesByIds
|
|
56
|
+
|
|
57
|
+
const [input, setInput] = React.useState('')
|
|
58
|
+
const [suggestions, setSuggestions] = React.useState<AssociationOption[]>([])
|
|
59
|
+
const [cache, setCache] = React.useState<Map<string, AssociationOption>>(() => new Map())
|
|
60
|
+
const [loading, setLoading] = React.useState(false)
|
|
61
|
+
const [error, setError] = React.useState<string | null>(null)
|
|
62
|
+
|
|
63
|
+
const normalizedValue = React.useMemo(() => sanitizeIdList(value), [value])
|
|
64
|
+
|
|
65
|
+
React.useEffect(() => {
|
|
66
|
+
if (!normalizedValue.length) return
|
|
67
|
+
const missing = normalizedValue.filter((id) => !cache.has(id))
|
|
68
|
+
if (!missing.length) return
|
|
69
|
+
let cancelled = false
|
|
70
|
+
;(async () => {
|
|
71
|
+
try {
|
|
72
|
+
const entries = await fetchByIds(missing)
|
|
73
|
+
if (cancelled) return
|
|
74
|
+
setCache((prev) => {
|
|
75
|
+
const next = new Map(prev)
|
|
76
|
+
entries.forEach((entry) => {
|
|
77
|
+
if (entry?.id) next.set(entry.id, entry)
|
|
78
|
+
})
|
|
79
|
+
return next
|
|
80
|
+
})
|
|
81
|
+
} catch {
|
|
82
|
+
if (!cancelled) setError(labels.error)
|
|
83
|
+
}
|
|
84
|
+
})().catch(() => {
|
|
85
|
+
// The inner try/catch already surfaces failures via setError; this guards the IIFE promise only.
|
|
86
|
+
})
|
|
87
|
+
return () => {
|
|
88
|
+
cancelled = true
|
|
89
|
+
}
|
|
90
|
+
}, [cache, fetchByIds, labels.error, normalizedValue])
|
|
91
|
+
|
|
92
|
+
React.useEffect(() => {
|
|
93
|
+
const query = input.trim()
|
|
94
|
+
// Only query once the operator starts typing. Searching on an empty string
|
|
95
|
+
// returns the first page of *every* person/company in the tenant — useless as
|
|
96
|
+
// a suggestion list and a performance trap at scale (thousands of records).
|
|
97
|
+
if (disabled || query.length === 0) {
|
|
98
|
+
setLoading(false)
|
|
99
|
+
setSuggestions([])
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
let cancelled = false
|
|
103
|
+
const handler = window.setTimeout(async () => {
|
|
104
|
+
setLoading(true)
|
|
105
|
+
try {
|
|
106
|
+
const results = await search(query)
|
|
107
|
+
if (cancelled) return
|
|
108
|
+
setSuggestions(results)
|
|
109
|
+
setCache((prev) => {
|
|
110
|
+
const next = new Map(prev)
|
|
111
|
+
results.forEach((entry) => {
|
|
112
|
+
if (entry?.id) next.set(entry.id, entry)
|
|
113
|
+
})
|
|
114
|
+
return next
|
|
115
|
+
})
|
|
116
|
+
setError(null)
|
|
117
|
+
} catch {
|
|
118
|
+
if (!cancelled) {
|
|
119
|
+
setError(labels.error)
|
|
120
|
+
setSuggestions([])
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
if (!cancelled) setLoading(false)
|
|
124
|
+
}
|
|
125
|
+
}, 200)
|
|
126
|
+
return () => {
|
|
127
|
+
cancelled = true
|
|
128
|
+
window.clearTimeout(handler)
|
|
129
|
+
}
|
|
130
|
+
}, [disabled, input, labels.error, search])
|
|
131
|
+
|
|
132
|
+
const filteredSuggestions = React.useMemo(
|
|
133
|
+
() => suggestions.filter((option) => !normalizedValue.includes(option.id)),
|
|
134
|
+
[normalizedValue, suggestions],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const selectedOptions = React.useMemo(
|
|
138
|
+
() => normalizedValue.map((id) => cache.get(id) ?? { id, label: id }),
|
|
139
|
+
[cache, normalizedValue],
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
const addOption = React.useCallback(
|
|
143
|
+
(option: AssociationOption) => {
|
|
144
|
+
if (!option?.id) return
|
|
145
|
+
if (normalizedValue.includes(option.id)) return
|
|
146
|
+
onChange([...normalizedValue, option.id])
|
|
147
|
+
setCache((prev) => {
|
|
148
|
+
const next = new Map(prev)
|
|
149
|
+
next.set(option.id, option)
|
|
150
|
+
return next
|
|
151
|
+
})
|
|
152
|
+
setInput('')
|
|
153
|
+
setSuggestions([])
|
|
154
|
+
},
|
|
155
|
+
[normalizedValue, onChange],
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
const removeOption = React.useCallback(
|
|
159
|
+
(id: string) => {
|
|
160
|
+
onChange(normalizedValue.filter((candidate) => candidate !== id))
|
|
161
|
+
},
|
|
162
|
+
[normalizedValue, onChange],
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
const renderLeading = React.useCallback(
|
|
166
|
+
(option: AssociationOption) =>
|
|
167
|
+
kind === 'people' ? (
|
|
168
|
+
<Avatar label={option.label} size="xs" />
|
|
169
|
+
) : (
|
|
170
|
+
<Building2 className="size-3.5 text-muted-foreground" aria-hidden="true" />
|
|
171
|
+
),
|
|
172
|
+
[kind],
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<div className="space-y-2">
|
|
177
|
+
<div className="flex flex-col gap-2 rounded-md border border-input bg-background p-2">
|
|
178
|
+
{selectedOptions.length ? (
|
|
179
|
+
<div className="flex flex-wrap gap-2">
|
|
180
|
+
{selectedOptions.map((option) => (
|
|
181
|
+
<span
|
|
182
|
+
key={option.id}
|
|
183
|
+
className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs text-foreground"
|
|
184
|
+
>
|
|
185
|
+
{renderLeading(option)}
|
|
186
|
+
<span className="truncate">{option.label}</span>
|
|
187
|
+
<IconButton
|
|
188
|
+
type="button"
|
|
189
|
+
variant="ghost"
|
|
190
|
+
size="xs"
|
|
191
|
+
aria-label={`${labels.remove} ${option.label}`}
|
|
192
|
+
onClick={() => removeOption(option.id)}
|
|
193
|
+
disabled={disabled}
|
|
194
|
+
>
|
|
195
|
+
<X className="size-3" />
|
|
196
|
+
</IconButton>
|
|
197
|
+
</span>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
) : null}
|
|
201
|
+
<SearchInput
|
|
202
|
+
id={id}
|
|
203
|
+
size="default"
|
|
204
|
+
value={input}
|
|
205
|
+
onChange={setInput}
|
|
206
|
+
placeholder={labels.placeholder}
|
|
207
|
+
disabled={disabled}
|
|
208
|
+
onKeyDown={(event) => {
|
|
209
|
+
if (event.key === 'Enter') {
|
|
210
|
+
event.preventDefault()
|
|
211
|
+
const nextOption = filteredSuggestions[0]
|
|
212
|
+
if (nextOption) addOption(nextOption)
|
|
213
|
+
} else if (event.key === 'Backspace' && !input.length && normalizedValue.length) {
|
|
214
|
+
removeOption(normalizedValue[normalizedValue.length - 1])
|
|
215
|
+
}
|
|
216
|
+
}}
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
{loading ? <div className="text-xs text-muted-foreground">{labels.loading}</div> : null}
|
|
220
|
+
{!loading && input.trim().length > 0 && filteredSuggestions.length ? (
|
|
221
|
+
<div className="flex max-h-72 flex-col gap-1 overflow-y-auto rounded-md border border-border bg-popover p-1 shadow-md">
|
|
222
|
+
{filteredSuggestions.slice(0, 8).map((option) => (
|
|
223
|
+
<Button
|
|
224
|
+
key={option.id}
|
|
225
|
+
type="button"
|
|
226
|
+
variant="ghost"
|
|
227
|
+
size="sm"
|
|
228
|
+
className="h-auto justify-start px-2 py-1 text-left font-normal"
|
|
229
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
230
|
+
onClick={() => addOption(option)}
|
|
231
|
+
disabled={disabled}
|
|
232
|
+
aria-label={option.label}
|
|
233
|
+
>
|
|
234
|
+
{renderLeading(option)}
|
|
235
|
+
<span className="flex min-w-0 flex-col items-start">
|
|
236
|
+
<span className="truncate text-sm">{option.label}</span>
|
|
237
|
+
{option.subtitle ? (
|
|
238
|
+
<span className="truncate text-xs text-muted-foreground">{option.subtitle}</span>
|
|
239
|
+
) : null}
|
|
240
|
+
</span>
|
|
241
|
+
</Button>
|
|
242
|
+
))}
|
|
243
|
+
</div>
|
|
244
|
+
) : null}
|
|
245
|
+
{!loading && input.trim().length > 0 && !filteredSuggestions.length ? (
|
|
246
|
+
<div className="text-xs text-muted-foreground">{labels.noResults}</div>
|
|
247
|
+
) : null}
|
|
248
|
+
{error ? <div className="text-xs text-status-error-text">{error}</div> : null}
|
|
249
|
+
</div>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export default DealAssociationsField
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Users } from 'lucide-react'
|
|
5
|
+
import { DealSectionCard } from './DealSectionCard'
|
|
6
|
+
import { DealFormField } from './DealFormField'
|
|
7
|
+
import { DealAssociationsField } from './DealAssociationsField'
|
|
8
|
+
import type { Translate } from './dealFormTypes'
|
|
9
|
+
|
|
10
|
+
export type DealAssociationsSectionProps = {
|
|
11
|
+
tr: Translate
|
|
12
|
+
personIds: string[]
|
|
13
|
+
companyIds: string[]
|
|
14
|
+
onPeopleChange: (next: string[]) => void
|
|
15
|
+
onCompaniesChange: (next: string[]) => void
|
|
16
|
+
disabled: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function DealAssociationsSection({
|
|
20
|
+
tr,
|
|
21
|
+
personIds,
|
|
22
|
+
companyIds,
|
|
23
|
+
onPeopleChange,
|
|
24
|
+
onCompaniesChange,
|
|
25
|
+
disabled,
|
|
26
|
+
}: DealAssociationsSectionProps) {
|
|
27
|
+
const peopleLabels = {
|
|
28
|
+
placeholder: tr('customers.deals.create.associations.peoplePlaceholder', 'Search people by name or email…'),
|
|
29
|
+
empty: tr('customers.deals.form.people.empty', 'No people linked yet.'),
|
|
30
|
+
loading: tr('customers.deals.form.people.loading', 'Searching people…'),
|
|
31
|
+
noResults: tr('customers.deals.form.people.noResults', 'No people match your search.'),
|
|
32
|
+
remove: tr('customers.deals.form.assignees.remove', 'Remove'),
|
|
33
|
+
error: tr('customers.deals.form.people.error', 'Failed to load people.'),
|
|
34
|
+
}
|
|
35
|
+
const companyLabels = {
|
|
36
|
+
placeholder: tr('customers.deals.create.associations.companiesPlaceholder', 'Search companies by name or domain…'),
|
|
37
|
+
empty: tr('customers.deals.form.companies.empty', 'No companies linked yet.'),
|
|
38
|
+
loading: tr('customers.deals.form.companies.loading', 'Searching companies…'),
|
|
39
|
+
noResults: tr('customers.deals.form.companies.noResults', 'No companies match your search.'),
|
|
40
|
+
remove: tr('customers.deals.form.assignees.remove', 'Remove'),
|
|
41
|
+
error: tr('customers.deals.form.companies.error', 'Failed to load companies.'),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<DealSectionCard
|
|
46
|
+
icon={Users}
|
|
47
|
+
title={tr('customers.deals.create.sections.associations.title', 'Associations')}
|
|
48
|
+
subtitle={tr('customers.deals.create.sections.associations.subtitle', 'Link people and companies to this deal')}
|
|
49
|
+
>
|
|
50
|
+
<DealFormField fieldId="personIds" label={tr('customers.people.detail.deals.fields.people', 'People')}>
|
|
51
|
+
<DealAssociationsField
|
|
52
|
+
kind="people"
|
|
53
|
+
value={personIds}
|
|
54
|
+
onChange={onPeopleChange}
|
|
55
|
+
disabled={disabled}
|
|
56
|
+
labels={peopleLabels}
|
|
57
|
+
/>
|
|
58
|
+
</DealFormField>
|
|
59
|
+
<DealFormField fieldId="companyIds" label={tr('customers.people.detail.deals.fields.companies', 'Companies')}>
|
|
60
|
+
<DealAssociationsField
|
|
61
|
+
kind="companies"
|
|
62
|
+
value={companyIds}
|
|
63
|
+
onChange={onCompaniesChange}
|
|
64
|
+
disabled={disabled}
|
|
65
|
+
labels={companyLabels}
|
|
66
|
+
/>
|
|
67
|
+
</DealFormField>
|
|
68
|
+
</DealSectionCard>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default DealAssociationsSection
|