@open-mercato/core 0.6.4-develop.3929.1.fcf7afece2 → 0.6.4-develop.3944.1.4100aa7fbe
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/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/package.json +7 -7
- 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
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { format } from 'date-fns/format'
|
|
5
|
+
import { parseISO } from 'date-fns/parseISO'
|
|
6
|
+
import type { CrudField, CrudFieldOption } from '@open-mercato/ui/backend/CrudForm'
|
|
7
|
+
import { Label } from '@open-mercato/ui/primitives/label'
|
|
8
|
+
import { Input } from '@open-mercato/ui/primitives/input'
|
|
9
|
+
import { Textarea } from '@open-mercato/ui/primitives/textarea'
|
|
10
|
+
import { CheckboxField } from '@open-mercato/ui/primitives/checkbox-field'
|
|
11
|
+
import { DatePicker } from '@open-mercato/ui/primitives/date-picker'
|
|
12
|
+
import { TagInput } from '@open-mercato/ui/primitives/tag-input'
|
|
13
|
+
import {
|
|
14
|
+
Select,
|
|
15
|
+
SelectContent,
|
|
16
|
+
SelectItem,
|
|
17
|
+
SelectTrigger,
|
|
18
|
+
SelectValue,
|
|
19
|
+
} from '@open-mercato/ui/primitives/select'
|
|
20
|
+
import { Checkbox } from '@open-mercato/ui/primitives/checkbox'
|
|
21
|
+
|
|
22
|
+
export type DealCustomFieldControlProps = {
|
|
23
|
+
field: CrudField
|
|
24
|
+
value: unknown
|
|
25
|
+
onChange: (value: unknown) => void
|
|
26
|
+
error?: string
|
|
27
|
+
disabled?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SELECT_CLEAR_SENTINEL = '__deal_custom_field_select_clear__'
|
|
31
|
+
|
|
32
|
+
function toStringArray(value: unknown): string[] {
|
|
33
|
+
if (!Array.isArray(value)) return []
|
|
34
|
+
return value.filter((item): item is string => typeof item === 'string')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toDate(value: unknown): Date | null {
|
|
38
|
+
if (value instanceof Date) return value
|
|
39
|
+
if (typeof value === 'string' && value) {
|
|
40
|
+
const parsed = parseISO(value)
|
|
41
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed
|
|
42
|
+
}
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function useResolvedOptions(field: CrudField): CrudFieldOption[] {
|
|
47
|
+
const builtin = field.type === 'custom' ? null : field
|
|
48
|
+
const staticOptions = builtin?.options
|
|
49
|
+
const loadOptions = builtin?.loadOptions
|
|
50
|
+
const [loadedOptions, setLoadedOptions] = React.useState<CrudFieldOption[]>([])
|
|
51
|
+
|
|
52
|
+
React.useEffect(() => {
|
|
53
|
+
if (staticOptions && staticOptions.length > 0) return
|
|
54
|
+
if (typeof loadOptions !== 'function') return
|
|
55
|
+
let cancelled = false
|
|
56
|
+
loadOptions()
|
|
57
|
+
.then((options) => {
|
|
58
|
+
if (!cancelled) setLoadedOptions(options)
|
|
59
|
+
})
|
|
60
|
+
.catch(() => {
|
|
61
|
+
if (!cancelled) setLoadedOptions([])
|
|
62
|
+
})
|
|
63
|
+
return () => {
|
|
64
|
+
cancelled = true
|
|
65
|
+
}
|
|
66
|
+
}, [staticOptions, loadOptions])
|
|
67
|
+
|
|
68
|
+
if (staticOptions && staticOptions.length > 0) return staticOptions
|
|
69
|
+
return loadedOptions
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function FieldShell({
|
|
73
|
+
label,
|
|
74
|
+
required,
|
|
75
|
+
description,
|
|
76
|
+
error,
|
|
77
|
+
children,
|
|
78
|
+
}: {
|
|
79
|
+
label: string
|
|
80
|
+
required?: boolean
|
|
81
|
+
description?: React.ReactNode
|
|
82
|
+
error?: string
|
|
83
|
+
children: React.ReactNode
|
|
84
|
+
}) {
|
|
85
|
+
const fieldId = React.useId()
|
|
86
|
+
const control = React.isValidElement(children)
|
|
87
|
+
? React.cloneElement(children as React.ReactElement<{ id?: string }>, { id: fieldId })
|
|
88
|
+
: children
|
|
89
|
+
return (
|
|
90
|
+
<div className="space-y-1">
|
|
91
|
+
{label.trim().length > 0 ? (
|
|
92
|
+
<Label htmlFor={fieldId}>
|
|
93
|
+
{label}
|
|
94
|
+
{required ? <span className="text-destructive"> *</span> : null}
|
|
95
|
+
</Label>
|
|
96
|
+
) : null}
|
|
97
|
+
{control}
|
|
98
|
+
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
|
99
|
+
{error ? <p className="text-xs text-status-error-text">{error}</p> : null}
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function DealCustomFieldControl({
|
|
105
|
+
field,
|
|
106
|
+
value,
|
|
107
|
+
onChange,
|
|
108
|
+
error,
|
|
109
|
+
disabled = false,
|
|
110
|
+
}: DealCustomFieldControlProps) {
|
|
111
|
+
const options = useResolvedOptions(field)
|
|
112
|
+
const ariaInvalid = error ? true : undefined
|
|
113
|
+
|
|
114
|
+
if (field.type === 'checkbox') {
|
|
115
|
+
return (
|
|
116
|
+
<div className="space-y-1">
|
|
117
|
+
<CheckboxField
|
|
118
|
+
label={field.label}
|
|
119
|
+
description={field.description}
|
|
120
|
+
checked={value === true}
|
|
121
|
+
onCheckedChange={(next) => onChange(next === true)}
|
|
122
|
+
disabled={disabled}
|
|
123
|
+
aria-invalid={ariaInvalid}
|
|
124
|
+
/>
|
|
125
|
+
{error ? <p className="text-xs text-status-error-text">{error}</p> : null}
|
|
126
|
+
</div>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (field.type === 'custom') {
|
|
131
|
+
return (
|
|
132
|
+
<FieldShell
|
|
133
|
+
label={field.label}
|
|
134
|
+
required={field.required}
|
|
135
|
+
description={field.description}
|
|
136
|
+
error={error}
|
|
137
|
+
>
|
|
138
|
+
{field.component({
|
|
139
|
+
id: field.id,
|
|
140
|
+
value,
|
|
141
|
+
error,
|
|
142
|
+
setValue: onChange,
|
|
143
|
+
disabled,
|
|
144
|
+
autoFocus: false,
|
|
145
|
+
})}
|
|
146
|
+
</FieldShell>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let control: React.ReactNode
|
|
151
|
+
|
|
152
|
+
switch (field.type) {
|
|
153
|
+
case 'number':
|
|
154
|
+
control = (
|
|
155
|
+
<Input
|
|
156
|
+
type="number"
|
|
157
|
+
inputMode="numeric"
|
|
158
|
+
value={typeof value === 'number' || typeof value === 'string' ? String(value) : ''}
|
|
159
|
+
placeholder={field.placeholder}
|
|
160
|
+
disabled={disabled}
|
|
161
|
+
aria-invalid={ariaInvalid}
|
|
162
|
+
onChange={(event) => {
|
|
163
|
+
const raw = event.target.value
|
|
164
|
+
if (raw === '') {
|
|
165
|
+
onChange(undefined)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
const parsed = Number(raw)
|
|
169
|
+
onChange(Number.isNaN(parsed) ? raw : parsed)
|
|
170
|
+
}}
|
|
171
|
+
/>
|
|
172
|
+
)
|
|
173
|
+
break
|
|
174
|
+
case 'date':
|
|
175
|
+
case 'datepicker':
|
|
176
|
+
control = (
|
|
177
|
+
<DatePicker
|
|
178
|
+
value={toDate(value)}
|
|
179
|
+
onChange={(date) => onChange(date ? format(date, 'yyyy-MM-dd') : undefined)}
|
|
180
|
+
disabled={disabled}
|
|
181
|
+
placeholder={field.placeholder}
|
|
182
|
+
aria-invalid={ariaInvalid}
|
|
183
|
+
/>
|
|
184
|
+
)
|
|
185
|
+
break
|
|
186
|
+
case 'datetime':
|
|
187
|
+
case 'datetime-local':
|
|
188
|
+
control = (
|
|
189
|
+
<DatePicker
|
|
190
|
+
withTime
|
|
191
|
+
value={toDate(value)}
|
|
192
|
+
onChange={(date) => onChange(date ? date.toISOString() : undefined)}
|
|
193
|
+
disabled={disabled}
|
|
194
|
+
placeholder={field.placeholder}
|
|
195
|
+
aria-invalid={ariaInvalid}
|
|
196
|
+
/>
|
|
197
|
+
)
|
|
198
|
+
break
|
|
199
|
+
case 'tags':
|
|
200
|
+
control = (
|
|
201
|
+
<TagInput
|
|
202
|
+
value={toStringArray(value)}
|
|
203
|
+
onChange={(next) => onChange(next)}
|
|
204
|
+
placeholder={field.placeholder}
|
|
205
|
+
disabled={disabled}
|
|
206
|
+
aria-invalid={ariaInvalid}
|
|
207
|
+
/>
|
|
208
|
+
)
|
|
209
|
+
break
|
|
210
|
+
case 'richtext':
|
|
211
|
+
control = (
|
|
212
|
+
<Textarea
|
|
213
|
+
value={value == null ? '' : String(value)}
|
|
214
|
+
placeholder={field.placeholder}
|
|
215
|
+
disabled={disabled}
|
|
216
|
+
aria-invalid={ariaInvalid}
|
|
217
|
+
onChange={(event) => onChange(event.target.value)}
|
|
218
|
+
/>
|
|
219
|
+
)
|
|
220
|
+
break
|
|
221
|
+
case 'select': {
|
|
222
|
+
if (field.multiple) {
|
|
223
|
+
const selected = toStringArray(value)
|
|
224
|
+
control = (
|
|
225
|
+
<div className="flex flex-wrap gap-3">
|
|
226
|
+
{options.map((option) => {
|
|
227
|
+
const checked = selected.includes(option.value)
|
|
228
|
+
return (
|
|
229
|
+
<label key={option.value} className="inline-flex cursor-pointer items-center gap-2">
|
|
230
|
+
<Checkbox
|
|
231
|
+
checked={checked}
|
|
232
|
+
disabled={disabled}
|
|
233
|
+
onCheckedChange={(state) => {
|
|
234
|
+
const next = new Set(selected)
|
|
235
|
+
if (state === true) next.add(option.value)
|
|
236
|
+
else next.delete(option.value)
|
|
237
|
+
onChange(Array.from(next))
|
|
238
|
+
}}
|
|
239
|
+
/>
|
|
240
|
+
<span className="text-sm">{option.label}</span>
|
|
241
|
+
</label>
|
|
242
|
+
)
|
|
243
|
+
})}
|
|
244
|
+
</div>
|
|
245
|
+
)
|
|
246
|
+
} else {
|
|
247
|
+
const selectedValue = Array.isArray(value)
|
|
248
|
+
? String(value[0] ?? '')
|
|
249
|
+
: value == null
|
|
250
|
+
? ''
|
|
251
|
+
: String(value)
|
|
252
|
+
control = (
|
|
253
|
+
<Select
|
|
254
|
+
value={selectedValue}
|
|
255
|
+
disabled={disabled}
|
|
256
|
+
onValueChange={(next) => {
|
|
257
|
+
if (!next || next === SELECT_CLEAR_SENTINEL) {
|
|
258
|
+
onChange(null)
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
onChange(next)
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
<SelectTrigger aria-invalid={ariaInvalid}>
|
|
265
|
+
<SelectValue placeholder={field.placeholder} />
|
|
266
|
+
</SelectTrigger>
|
|
267
|
+
<SelectContent>
|
|
268
|
+
{!field.required && selectedValue ? (
|
|
269
|
+
<SelectItem value={SELECT_CLEAR_SENTINEL}>—</SelectItem>
|
|
270
|
+
) : null}
|
|
271
|
+
{options
|
|
272
|
+
.filter((option) => option.value !== '')
|
|
273
|
+
.map((option) => (
|
|
274
|
+
<SelectItem key={option.value} value={option.value}>
|
|
275
|
+
{option.label}
|
|
276
|
+
</SelectItem>
|
|
277
|
+
))}
|
|
278
|
+
</SelectContent>
|
|
279
|
+
</Select>
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
break
|
|
283
|
+
}
|
|
284
|
+
case 'text':
|
|
285
|
+
default:
|
|
286
|
+
control = (
|
|
287
|
+
<Input
|
|
288
|
+
value={value == null ? '' : String(value)}
|
|
289
|
+
placeholder={field.placeholder}
|
|
290
|
+
disabled={disabled}
|
|
291
|
+
aria-invalid={ariaInvalid}
|
|
292
|
+
onChange={(event) => onChange(event.target.value)}
|
|
293
|
+
/>
|
|
294
|
+
)
|
|
295
|
+
break
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
<FieldShell
|
|
300
|
+
label={field.label}
|
|
301
|
+
required={field.required}
|
|
302
|
+
description={field.description}
|
|
303
|
+
error={error}
|
|
304
|
+
>
|
|
305
|
+
{control}
|
|
306
|
+
</FieldShell>
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export default DealCustomFieldControl
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type Translate = (key: string, fallback: string, params?: Record<string, string | number>) => string
|
|
2
|
+
|
|
3
|
+
export type BaseValues = {
|
|
4
|
+
title: string
|
|
5
|
+
status: string
|
|
6
|
+
pipelineId: string
|
|
7
|
+
pipelineStageId: string
|
|
8
|
+
valueAmount: string
|
|
9
|
+
valueCurrency: string
|
|
10
|
+
probability: string
|
|
11
|
+
expectedCloseAt: string
|
|
12
|
+
description: string
|
|
13
|
+
personIds: string[]
|
|
14
|
+
companyIds: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const EMPTY_VALUES: BaseValues = {
|
|
18
|
+
title: '',
|
|
19
|
+
status: '',
|
|
20
|
+
pipelineId: '',
|
|
21
|
+
pipelineStageId: '',
|
|
22
|
+
valueAmount: '',
|
|
23
|
+
valueCurrency: '',
|
|
24
|
+
probability: '',
|
|
25
|
+
expectedCloseAt: '',
|
|
26
|
+
description: '',
|
|
27
|
+
personIds: [],
|
|
28
|
+
companyIds: [],
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input sanitizers for the deal create form's numeric fields. They guarantee the
|
|
3
|
+
* underlying state only ever holds a valid numeric string (or empty), so non-numeric
|
|
4
|
+
* input can never reach the zod schema and surface a raw "expected number" type error.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Keep only digits and a single decimal point; strips letters, signs, and extra dots. */
|
|
8
|
+
export function sanitizeAmount(raw: string): string {
|
|
9
|
+
const cleaned = raw.replace(/[^\d.]/g, '')
|
|
10
|
+
const firstDot = cleaned.indexOf('.')
|
|
11
|
+
if (firstDot === -1) return cleaned
|
|
12
|
+
return cleaned.slice(0, firstDot + 1) + cleaned.slice(firstDot + 1).replace(/\./g, '')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Digits only, clamped to 0–100, so probability is always a valid percentage. */
|
|
16
|
+
export function sanitizeProbability(raw: string): string {
|
|
17
|
+
const digits = raw.replace(/\D/g, '')
|
|
18
|
+
if (!digits) return ''
|
|
19
|
+
return String(Math.min(100, Number(digits)))
|
|
20
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import {
|
|
5
|
+
validateValuesAgainstDefs,
|
|
6
|
+
type CustomFieldDefLike,
|
|
7
|
+
} from '@open-mercato/shared/modules/entities/validation'
|
|
8
|
+
import type { CrudField } from '@open-mercato/ui/backend/CrudForm'
|
|
9
|
+
import type { CustomFieldDefDto } from '@open-mercato/ui/backend/utils/customFieldDefs'
|
|
10
|
+
import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customFieldValues'
|
|
11
|
+
import { normalizeCustomFieldSubmitValue } from '../customFieldUtils'
|
|
12
|
+
import type { DealCustomAttributesLoadState } from './DealCustomAttributes'
|
|
13
|
+
import type { Translate } from './dealFormTypes'
|
|
14
|
+
|
|
15
|
+
function isEmptyCustomFieldValue(field: CrudField, value: unknown): boolean {
|
|
16
|
+
if (value === undefined || value === null) return true
|
|
17
|
+
if (typeof value === 'string') return value.trim() === ''
|
|
18
|
+
if (Array.isArray(value)) return value.length === 0
|
|
19
|
+
return field.type === 'checkbox' && value !== true
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function mapCustomDefinitionsForValidation(definitions: CustomFieldDefDto[]): CustomFieldDefLike[] {
|
|
23
|
+
return definitions.map((definition) => ({
|
|
24
|
+
key: definition.key,
|
|
25
|
+
kind: definition.kind,
|
|
26
|
+
configJson: {
|
|
27
|
+
validation: Array.isArray(definition.validation) ? definition.validation : [],
|
|
28
|
+
},
|
|
29
|
+
}))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type UseDealCustomFieldsResult = {
|
|
33
|
+
customValues: Record<string, unknown>
|
|
34
|
+
customFieldsLoaded: boolean
|
|
35
|
+
customCount: number
|
|
36
|
+
handleCustomChange: (key: string, value: unknown) => void
|
|
37
|
+
handleCustomAttributesLoaded: (state: DealCustomAttributesLoadState) => void
|
|
38
|
+
validateCustomFields: (source: Record<string, unknown>) => Record<string, string>
|
|
39
|
+
collectNormalizedCustomValues: (source: Record<string, unknown>) => Record<string, unknown>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useDealCustomFields(tr: Translate): UseDealCustomFieldsResult {
|
|
43
|
+
const [customValues, setCustomValues] = React.useState<Record<string, unknown>>({})
|
|
44
|
+
const [customFields, setCustomFields] = React.useState<CrudField[]>([])
|
|
45
|
+
const [customDefinitions, setCustomDefinitions] = React.useState<CustomFieldDefDto[]>([])
|
|
46
|
+
const [customFieldsLoaded, setCustomFieldsLoaded] = React.useState(false)
|
|
47
|
+
const [customCount, setCustomCount] = React.useState(0)
|
|
48
|
+
const customDefaultsAppliedRef = React.useRef(false)
|
|
49
|
+
|
|
50
|
+
const handleCustomChange = React.useCallback((key: string, value: unknown) => {
|
|
51
|
+
setCustomValues((current) => ({ ...current, [key]: value }))
|
|
52
|
+
}, [])
|
|
53
|
+
|
|
54
|
+
const handleCustomAttributesLoaded = React.useCallback((state: DealCustomAttributesLoadState) => {
|
|
55
|
+
setCustomFields(state.fields)
|
|
56
|
+
setCustomDefinitions(state.definitions)
|
|
57
|
+
setCustomFieldsLoaded(true)
|
|
58
|
+
setCustomCount(state.fields.length)
|
|
59
|
+
|
|
60
|
+
if (customDefaultsAppliedRef.current || state.definitions.length === 0) return
|
|
61
|
+
customDefaultsAppliedRef.current = true
|
|
62
|
+
setCustomValues((current) => {
|
|
63
|
+
let changed = false
|
|
64
|
+
const next = { ...current }
|
|
65
|
+
for (const definition of state.definitions) {
|
|
66
|
+
if (definition.defaultValue === undefined || definition.defaultValue === null) continue
|
|
67
|
+
const fieldId = `cf_${definition.key}`
|
|
68
|
+
if (next[fieldId] !== undefined) continue
|
|
69
|
+
next[fieldId] = definition.defaultValue
|
|
70
|
+
changed = true
|
|
71
|
+
}
|
|
72
|
+
return changed ? next : current
|
|
73
|
+
})
|
|
74
|
+
}, [])
|
|
75
|
+
|
|
76
|
+
const collectNormalizedCustomValues = React.useCallback((source: Record<string, unknown>) =>
|
|
77
|
+
collectCustomFieldValues(source, {
|
|
78
|
+
transform: (value) => normalizeCustomFieldSubmitValue(value),
|
|
79
|
+
}), [])
|
|
80
|
+
|
|
81
|
+
const validateCustomFields = React.useCallback((source: Record<string, unknown>) => {
|
|
82
|
+
const fieldErrors: Record<string, string> = {}
|
|
83
|
+
const requiredMessage = tr('ui.forms.errors.required', 'Required')
|
|
84
|
+
|
|
85
|
+
for (const field of customFields) {
|
|
86
|
+
if (!field.required) continue
|
|
87
|
+
if (isEmptyCustomFieldValue(field, source[field.id])) {
|
|
88
|
+
fieldErrors[field.id] = requiredMessage
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (customDefinitions.length > 0) {
|
|
93
|
+
const result = validateValuesAgainstDefs(
|
|
94
|
+
collectNormalizedCustomValues(source),
|
|
95
|
+
mapCustomDefinitionsForValidation(customDefinitions),
|
|
96
|
+
)
|
|
97
|
+
if (!result.ok) {
|
|
98
|
+
for (const [fieldId, message] of Object.entries(result.fieldErrors)) {
|
|
99
|
+
if (!fieldErrors[fieldId]) fieldErrors[fieldId] = tr(message, message)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return fieldErrors
|
|
105
|
+
}, [collectNormalizedCustomValues, customDefinitions, customFields, tr])
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
customValues,
|
|
109
|
+
customFieldsLoaded,
|
|
110
|
+
customCount,
|
|
111
|
+
handleCustomChange,
|
|
112
|
+
handleCustomAttributesLoaded,
|
|
113
|
+
validateCustomFields,
|
|
114
|
+
collectNormalizedCustomValues,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default useDealCustomFields
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
5
|
+
|
|
6
|
+
export type PipelineOption = {
|
|
7
|
+
id: string
|
|
8
|
+
name: string
|
|
9
|
+
isDefault: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type PipelineStageOption = {
|
|
13
|
+
id: string
|
|
14
|
+
label: string
|
|
15
|
+
order: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type UseDealPipelinesResult = {
|
|
19
|
+
pipelines: PipelineOption[]
|
|
20
|
+
stages: PipelineStageOption[]
|
|
21
|
+
loadStages: (pipelineId: string) => Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useDealPipelines(): UseDealPipelinesResult {
|
|
25
|
+
const [pipelines, setPipelines] = React.useState<PipelineOption[]>([])
|
|
26
|
+
const [stages, setStages] = React.useState<PipelineStageOption[]>([])
|
|
27
|
+
const mountedRef = React.useRef(true)
|
|
28
|
+
|
|
29
|
+
React.useEffect(() => {
|
|
30
|
+
mountedRef.current = true
|
|
31
|
+
return () => {
|
|
32
|
+
mountedRef.current = false
|
|
33
|
+
}
|
|
34
|
+
}, [])
|
|
35
|
+
|
|
36
|
+
React.useEffect(() => {
|
|
37
|
+
let cancelled = false
|
|
38
|
+
;(async () => {
|
|
39
|
+
try {
|
|
40
|
+
const call = await apiCall<{ items: PipelineOption[] }>('/api/customers/pipelines')
|
|
41
|
+
if (cancelled || !mountedRef.current) return
|
|
42
|
+
if (call.ok && call.result?.items) {
|
|
43
|
+
setPipelines(call.result.items)
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
if (!cancelled && mountedRef.current) setPipelines([])
|
|
47
|
+
}
|
|
48
|
+
})().catch(() => {
|
|
49
|
+
// The inner try/catch already resets pipelines on failure; this guards the IIFE promise only.
|
|
50
|
+
})
|
|
51
|
+
return () => {
|
|
52
|
+
cancelled = true
|
|
53
|
+
}
|
|
54
|
+
}, [])
|
|
55
|
+
|
|
56
|
+
const loadStages = React.useCallback(async (pipelineId: string) => {
|
|
57
|
+
if (!pipelineId) {
|
|
58
|
+
if (mountedRef.current) setStages([])
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const call = await apiCall<{ items: PipelineStageOption[] }>(
|
|
63
|
+
`/api/customers/pipeline-stages?pipelineId=${encodeURIComponent(pipelineId)}`,
|
|
64
|
+
)
|
|
65
|
+
if (!mountedRef.current) return
|
|
66
|
+
if (call.ok && call.result?.items) {
|
|
67
|
+
const sorted = [...call.result.items].sort((a, b) => a.order - b.order)
|
|
68
|
+
setStages(sorted)
|
|
69
|
+
} else {
|
|
70
|
+
setStages([])
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
if (mountedRef.current) setStages([])
|
|
74
|
+
}
|
|
75
|
+
}, [])
|
|
76
|
+
|
|
77
|
+
return { pipelines, stages, loadStages }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default useDealPipelines
|
|
@@ -113,6 +113,7 @@ type DictionarySelectFieldProps = {
|
|
|
113
113
|
allowAppearance?: boolean
|
|
114
114
|
showManage?: boolean
|
|
115
115
|
showLabelInput?: boolean
|
|
116
|
+
showActiveAppearance?: boolean
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
const emailValidationSchema = z.string().email()
|
|
@@ -141,6 +142,7 @@ export function DictionarySelectField({
|
|
|
141
142
|
allowAppearance = false,
|
|
142
143
|
showManage = false,
|
|
143
144
|
showLabelInput = true,
|
|
145
|
+
showActiveAppearance = true,
|
|
144
146
|
}: DictionarySelectFieldProps) {
|
|
145
147
|
const t = useT()
|
|
146
148
|
const queryClient = useQueryClient()
|
|
@@ -237,6 +239,7 @@ export function DictionarySelectField({
|
|
|
237
239
|
allowAppearance={allowAppearance}
|
|
238
240
|
showManage={showManage}
|
|
239
241
|
showLabelInput={showLabelInput}
|
|
242
|
+
showActiveAppearance={showActiveAppearance}
|
|
240
243
|
/>
|
|
241
244
|
)
|
|
242
245
|
}
|
|
@@ -665,8 +665,34 @@
|
|
|
665
665
|
"customers.deal_analyzer.sheet.dock": "Seitlich andocken",
|
|
666
666
|
"customers.deal_analyzer.sheet.title": "Deal-Analyse",
|
|
667
667
|
"customers.deal_analyzer.sheet.welcomeTitle": "Deal-Analyse",
|
|
668
|
+
"customers.deals.create.associations.companiesPlaceholder": "Unternehmen nach Name oder Domain suchen…",
|
|
669
|
+
"customers.deals.create.associations.peoplePlaceholder": "Personen nach Name oder E-Mail suchen…",
|
|
670
|
+
"customers.deals.create.back": "Zurück zu Deals",
|
|
671
|
+
"customers.deals.create.cancel": "Abbrechen",
|
|
668
672
|
"customers.deals.create.error": "Deal konnte nicht erstellt werden.",
|
|
673
|
+
"customers.deals.create.fields.datePlaceholder": "Datum auswählen",
|
|
674
|
+
"customers.deals.create.fields.expectedCloseAt": "Voraussichtliches Abschlussdatum",
|
|
675
|
+
"customers.deals.create.fields.probability": "Wahrscheinlichkeit",
|
|
676
|
+
"customers.deals.create.fields.stageOf": "· Phase {position} von {total}",
|
|
677
|
+
"customers.deals.create.fields.title": "Deal-Titel",
|
|
678
|
+
"customers.deals.create.fields.valueAmount": "Deal-Wert",
|
|
679
|
+
"customers.deals.create.hints.pipelineStage": "Die Phasen hängen von der ausgewählten Pipeline ab",
|
|
680
|
+
"customers.deals.create.hints.probability": "0 – 100 %, für den gewichteten Pipeline-Wert verwendet",
|
|
681
|
+
"customers.deals.create.hints.title": "Kurzer, aussagekräftiger Name auf den Pipeline-Karten",
|
|
682
|
+
"customers.deals.create.hints.valueAmount": "Potenzieller Umsatz aus dieser Opportunity",
|
|
683
|
+
"customers.deals.create.sections.associations.subtitle": "Personen und Unternehmen mit diesem Deal verknüpfen",
|
|
684
|
+
"customers.deals.create.sections.associations.title": "Verknüpfungen",
|
|
685
|
+
"customers.deals.create.sections.custom.empty": "Noch keine benutzerdefinierten Felder für Deals definiert.",
|
|
686
|
+
"customers.deals.create.sections.custom.loading": "Benutzerdefinierte Felder werden geladen…",
|
|
687
|
+
"customers.deals.create.sections.custom.manage": "Felder verwalten",
|
|
688
|
+
"customers.deals.create.sections.custom.subtitle": "{count} Felder für diesen Mandanten definiert",
|
|
689
|
+
"customers.deals.create.sections.custom.title": "Benutzerdefinierte Attribute",
|
|
690
|
+
"customers.deals.create.sections.details.subtitle": "Wichtigste Opportunity-Infos",
|
|
669
691
|
"customers.deals.create.submit": "Deal erstellen",
|
|
692
|
+
"customers.deals.create.tips.item1": "Verwenden Sie im Titel das Format Firmenname + kurzes Ergebnis (z. B. \"Copperleaf — Q3 Renewal\")",
|
|
693
|
+
"customers.deals.create.tips.item2": "Legen Sie die Wahrscheinlichkeit nach Pipeline-Phase fest: Qualifizierung 10-25 %, Angebot 30-50 %, Verhandlung 50-75 %, Vertrag 75-90 %",
|
|
694
|
+
"customers.deals.create.tips.item3": "Verknüpfen Sie den wichtigsten Entscheider als erste Person — er erhält standardmäßig die E-Mail-CC bei Aktivitäten",
|
|
695
|
+
"customers.deals.create.tips.title": "Tipps für bessere Deals",
|
|
670
696
|
"customers.deals.create.title": "Deal erstellen",
|
|
671
697
|
"customers.deals.detail.actions.apply": "Apply",
|
|
672
698
|
"customers.deals.detail.actions.backToList": "Zurück zu Deals",
|
|
@@ -665,8 +665,34 @@
|
|
|
665
665
|
"customers.deal_analyzer.sheet.dock": "Dock to side",
|
|
666
666
|
"customers.deal_analyzer.sheet.title": "Deal Analyzer",
|
|
667
667
|
"customers.deal_analyzer.sheet.welcomeTitle": "Deal Analyzer",
|
|
668
|
+
"customers.deals.create.associations.companiesPlaceholder": "Search companies by name or domain…",
|
|
669
|
+
"customers.deals.create.associations.peoplePlaceholder": "Search people by name or email…",
|
|
670
|
+
"customers.deals.create.back": "Back to deals",
|
|
671
|
+
"customers.deals.create.cancel": "Cancel",
|
|
668
672
|
"customers.deals.create.error": "Failed to create deal.",
|
|
673
|
+
"customers.deals.create.fields.datePlaceholder": "Pick a date",
|
|
674
|
+
"customers.deals.create.fields.expectedCloseAt": "Expected close date",
|
|
675
|
+
"customers.deals.create.fields.probability": "Probability",
|
|
676
|
+
"customers.deals.create.fields.stageOf": "· stage {position} of {total}",
|
|
677
|
+
"customers.deals.create.fields.title": "Deal title",
|
|
678
|
+
"customers.deals.create.fields.valueAmount": "Deal value",
|
|
679
|
+
"customers.deals.create.hints.pipelineStage": "Stages depend on the selected pipeline",
|
|
680
|
+
"customers.deals.create.hints.probability": "0 – 100%, used for weighted pipeline value",
|
|
681
|
+
"customers.deals.create.hints.title": "Short, descriptive name shown on pipeline cards",
|
|
682
|
+
"customers.deals.create.hints.valueAmount": "Potential revenue from this opportunity",
|
|
683
|
+
"customers.deals.create.sections.associations.subtitle": "Link people and companies to this deal",
|
|
684
|
+
"customers.deals.create.sections.associations.title": "Associations",
|
|
685
|
+
"customers.deals.create.sections.custom.empty": "No custom fields defined for deals yet.",
|
|
686
|
+
"customers.deals.create.sections.custom.loading": "Loading custom fields…",
|
|
687
|
+
"customers.deals.create.sections.custom.manage": "Manage fields",
|
|
688
|
+
"customers.deals.create.sections.custom.subtitle": "{count} fields defined for this tenant",
|
|
689
|
+
"customers.deals.create.sections.custom.title": "Custom attributes",
|
|
690
|
+
"customers.deals.create.sections.details.subtitle": "Core opportunity info",
|
|
669
691
|
"customers.deals.create.submit": "Create deal",
|
|
692
|
+
"customers.deals.create.tips.item1": "Use the company name + short deliverable format in the title (e.g. \"Copperleaf — Q3 Renewal\")",
|
|
693
|
+
"customers.deals.create.tips.item2": "Set probability based on pipeline stage: Qual 10-25%, Proposal 30-50%, Negotiation 50-75%, Contract 75-90%",
|
|
694
|
+
"customers.deals.create.tips.item3": "Link primary decision maker as first person — they get default email CC on activities",
|
|
695
|
+
"customers.deals.create.tips.title": "Tips for better deals",
|
|
670
696
|
"customers.deals.create.title": "Create deal",
|
|
671
697
|
"customers.deals.detail.actions.apply": "Apply",
|
|
672
698
|
"customers.deals.detail.actions.backToList": "Back to deals",
|