@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.
Files changed (72) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/backend/customers/deals/create/page.js +3 -61
  3. package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
  4. package/dist/modules/customers/components/detail/DealForm.js +2 -0
  5. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  6. package/dist/modules/customers/components/detail/create/CreateDealForm.js +233 -0
  7. package/dist/modules/customers/components/detail/create/CreateDealForm.js.map +7 -0
  8. package/dist/modules/customers/components/detail/create/DealAssociationsField.js +209 -0
  9. package/dist/modules/customers/components/detail/create/DealAssociationsField.js.map +7 -0
  10. package/dist/modules/customers/components/detail/create/DealAssociationsSection.js +67 -0
  11. package/dist/modules/customers/components/detail/create/DealAssociationsSection.js.map +7 -0
  12. package/dist/modules/customers/components/detail/create/DealCreateSidebar.js +73 -0
  13. package/dist/modules/customers/components/detail/create/DealCreateSidebar.js.map +7 -0
  14. package/dist/modules/customers/components/detail/create/DealCurrencyField.js +92 -0
  15. package/dist/modules/customers/components/detail/create/DealCurrencyField.js.map +7 -0
  16. package/dist/modules/customers/components/detail/create/DealCustomAttributes.js +81 -0
  17. package/dist/modules/customers/components/detail/create/DealCustomAttributes.js.map +7 -0
  18. package/dist/modules/customers/components/detail/create/DealDetailsFields.js +171 -0
  19. package/dist/modules/customers/components/detail/create/DealDetailsFields.js.map +7 -0
  20. package/dist/modules/customers/components/detail/create/DealFormField.js +24 -0
  21. package/dist/modules/customers/components/detail/create/DealFormField.js.map +7 -0
  22. package/dist/modules/customers/components/detail/create/DealSectionCard.js +29 -0
  23. package/dist/modules/customers/components/detail/create/DealSectionCard.js.map +7 -0
  24. package/dist/modules/customers/components/detail/create/DealTipsCard.js +19 -0
  25. package/dist/modules/customers/components/detail/create/DealTipsCard.js.map +7 -0
  26. package/dist/modules/customers/components/detail/create/PipelineSelect.js +41 -0
  27. package/dist/modules/customers/components/detail/create/PipelineSelect.js.map +7 -0
  28. package/dist/modules/customers/components/detail/create/PipelineStageSelect.js +49 -0
  29. package/dist/modules/customers/components/detail/create/PipelineStageSelect.js.map +7 -0
  30. package/dist/modules/customers/components/detail/create/SuffixInput.js +21 -0
  31. package/dist/modules/customers/components/detail/create/SuffixInput.js.map +7 -0
  32. package/dist/modules/customers/components/detail/create/dealCustomFieldControl.js +270 -0
  33. package/dist/modules/customers/components/detail/create/dealCustomFieldControl.js.map +7 -0
  34. package/dist/modules/customers/components/detail/create/dealFormTypes.js +17 -0
  35. package/dist/modules/customers/components/detail/create/dealFormTypes.js.map +7 -0
  36. package/dist/modules/customers/components/detail/create/dealNumericInput.js +16 -0
  37. package/dist/modules/customers/components/detail/create/dealNumericInput.js.map +7 -0
  38. package/dist/modules/customers/components/detail/create/useDealCustomFields.js +93 -0
  39. package/dist/modules/customers/components/detail/create/useDealCustomFields.js.map +7 -0
  40. package/dist/modules/customers/components/detail/create/useDealPipelines.js +59 -0
  41. package/dist/modules/customers/components/detail/create/useDealPipelines.js.map +7 -0
  42. package/dist/modules/customers/components/formConfig.js +4 -2
  43. package/dist/modules/customers/components/formConfig.js.map +2 -2
  44. package/dist/modules/dictionaries/components/DictionaryEntrySelect.js +5 -2
  45. package/dist/modules/dictionaries/components/DictionaryEntrySelect.js.map +2 -2
  46. package/package.json +7 -7
  47. package/src/modules/customers/backend/customers/deals/create/page.tsx +3 -64
  48. package/src/modules/customers/components/detail/DealForm.tsx +2 -0
  49. package/src/modules/customers/components/detail/create/CreateDealForm.tsx +254 -0
  50. package/src/modules/customers/components/detail/create/DealAssociationsField.tsx +253 -0
  51. package/src/modules/customers/components/detail/create/DealAssociationsSection.tsx +72 -0
  52. package/src/modules/customers/components/detail/create/DealCreateSidebar.tsx +79 -0
  53. package/src/modules/customers/components/detail/create/DealCurrencyField.tsx +108 -0
  54. package/src/modules/customers/components/detail/create/DealCustomAttributes.tsx +118 -0
  55. package/src/modules/customers/components/detail/create/DealDetailsFields.tsx +171 -0
  56. package/src/modules/customers/components/detail/create/DealFormField.tsx +39 -0
  57. package/src/modules/customers/components/detail/create/DealSectionCard.tsx +40 -0
  58. package/src/modules/customers/components/detail/create/DealTipsCard.tsx +26 -0
  59. package/src/modules/customers/components/detail/create/PipelineSelect.tsx +55 -0
  60. package/src/modules/customers/components/detail/create/PipelineStageSelect.tsx +70 -0
  61. package/src/modules/customers/components/detail/create/SuffixInput.tsx +20 -0
  62. package/src/modules/customers/components/detail/create/dealCustomFieldControl.tsx +310 -0
  63. package/src/modules/customers/components/detail/create/dealFormTypes.ts +29 -0
  64. package/src/modules/customers/components/detail/create/dealNumericInput.ts +20 -0
  65. package/src/modules/customers/components/detail/create/useDealCustomFields.ts +118 -0
  66. package/src/modules/customers/components/detail/create/useDealPipelines.ts +80 -0
  67. package/src/modules/customers/components/formConfig.tsx +3 -0
  68. package/src/modules/customers/i18n/de.json +26 -0
  69. package/src/modules/customers/i18n/en.json +26 -0
  70. package/src/modules/customers/i18n/es.json +26 -0
  71. package/src/modules/customers/i18n/pl.json +26 -0
  72. 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",