@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.
Files changed (101) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/global.d.js +1 -0
  3. package/dist/global.d.js.map +7 -0
  4. package/dist/modules/catalog/commands/variants.js +11 -5
  5. package/dist/modules/catalog/commands/variants.js.map +2 -2
  6. package/dist/modules/customers/backend/customers/deals/create/page.js +3 -61
  7. package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
  8. package/dist/modules/customers/components/detail/DealForm.js +2 -0
  9. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  10. package/dist/modules/customers/components/detail/create/CreateDealForm.js +233 -0
  11. package/dist/modules/customers/components/detail/create/CreateDealForm.js.map +7 -0
  12. package/dist/modules/customers/components/detail/create/DealAssociationsField.js +209 -0
  13. package/dist/modules/customers/components/detail/create/DealAssociationsField.js.map +7 -0
  14. package/dist/modules/customers/components/detail/create/DealAssociationsSection.js +67 -0
  15. package/dist/modules/customers/components/detail/create/DealAssociationsSection.js.map +7 -0
  16. package/dist/modules/customers/components/detail/create/DealCreateSidebar.js +73 -0
  17. package/dist/modules/customers/components/detail/create/DealCreateSidebar.js.map +7 -0
  18. package/dist/modules/customers/components/detail/create/DealCurrencyField.js +92 -0
  19. package/dist/modules/customers/components/detail/create/DealCurrencyField.js.map +7 -0
  20. package/dist/modules/customers/components/detail/create/DealCustomAttributes.js +81 -0
  21. package/dist/modules/customers/components/detail/create/DealCustomAttributes.js.map +7 -0
  22. package/dist/modules/customers/components/detail/create/DealDetailsFields.js +171 -0
  23. package/dist/modules/customers/components/detail/create/DealDetailsFields.js.map +7 -0
  24. package/dist/modules/customers/components/detail/create/DealFormField.js +24 -0
  25. package/dist/modules/customers/components/detail/create/DealFormField.js.map +7 -0
  26. package/dist/modules/customers/components/detail/create/DealSectionCard.js +29 -0
  27. package/dist/modules/customers/components/detail/create/DealSectionCard.js.map +7 -0
  28. package/dist/modules/customers/components/detail/create/DealTipsCard.js +19 -0
  29. package/dist/modules/customers/components/detail/create/DealTipsCard.js.map +7 -0
  30. package/dist/modules/customers/components/detail/create/PipelineSelect.js +41 -0
  31. package/dist/modules/customers/components/detail/create/PipelineSelect.js.map +7 -0
  32. package/dist/modules/customers/components/detail/create/PipelineStageSelect.js +49 -0
  33. package/dist/modules/customers/components/detail/create/PipelineStageSelect.js.map +7 -0
  34. package/dist/modules/customers/components/detail/create/SuffixInput.js +21 -0
  35. package/dist/modules/customers/components/detail/create/SuffixInput.js.map +7 -0
  36. package/dist/modules/customers/components/detail/create/dealCustomFieldControl.js +270 -0
  37. package/dist/modules/customers/components/detail/create/dealCustomFieldControl.js.map +7 -0
  38. package/dist/modules/customers/components/detail/create/dealFormTypes.js +17 -0
  39. package/dist/modules/customers/components/detail/create/dealFormTypes.js.map +7 -0
  40. package/dist/modules/customers/components/detail/create/dealNumericInput.js +16 -0
  41. package/dist/modules/customers/components/detail/create/dealNumericInput.js.map +7 -0
  42. package/dist/modules/customers/components/detail/create/useDealCustomFields.js +93 -0
  43. package/dist/modules/customers/components/detail/create/useDealCustomFields.js.map +7 -0
  44. package/dist/modules/customers/components/detail/create/useDealPipelines.js +59 -0
  45. package/dist/modules/customers/components/detail/create/useDealPipelines.js.map +7 -0
  46. package/dist/modules/customers/components/formConfig.js +4 -2
  47. package/dist/modules/customers/components/formConfig.js.map +2 -2
  48. package/dist/modules/dictionaries/components/DictionaryEntrySelect.js +5 -2
  49. package/dist/modules/dictionaries/components/DictionaryEntrySelect.js.map +2 -2
  50. package/dist/modules/feature_toggles/lib/feature-flag-check.js +13 -5
  51. package/dist/modules/feature_toggles/lib/feature-flag-check.js.map +2 -2
  52. package/dist/modules/query_index/subscribers/coverage_refresh.js +6 -1
  53. package/dist/modules/query_index/subscribers/coverage_refresh.js.map +2 -2
  54. package/dist/modules/sync_excel/widgets/injection/upload-config/target-options.js +33 -5
  55. package/dist/modules/sync_excel/widgets/injection/upload-config/target-options.js.map +2 -2
  56. package/dist/modules/workflows/components/WorkflowGraph.js +29 -186
  57. package/dist/modules/workflows/components/WorkflowGraph.js.map +2 -2
  58. package/dist/modules/workflows/components/WorkflowGraphImpl.js +196 -0
  59. package/dist/modules/workflows/components/WorkflowGraphImpl.js.map +7 -0
  60. package/package.json +8 -9
  61. package/src/global.d.ts +9 -0
  62. package/src/modules/catalog/commands/variants.ts +14 -5
  63. package/src/modules/customers/backend/customers/deals/create/page.tsx +3 -64
  64. package/src/modules/customers/components/detail/DealForm.tsx +2 -0
  65. package/src/modules/customers/components/detail/create/CreateDealForm.tsx +254 -0
  66. package/src/modules/customers/components/detail/create/DealAssociationsField.tsx +253 -0
  67. package/src/modules/customers/components/detail/create/DealAssociationsSection.tsx +72 -0
  68. package/src/modules/customers/components/detail/create/DealCreateSidebar.tsx +79 -0
  69. package/src/modules/customers/components/detail/create/DealCurrencyField.tsx +108 -0
  70. package/src/modules/customers/components/detail/create/DealCustomAttributes.tsx +118 -0
  71. package/src/modules/customers/components/detail/create/DealDetailsFields.tsx +171 -0
  72. package/src/modules/customers/components/detail/create/DealFormField.tsx +39 -0
  73. package/src/modules/customers/components/detail/create/DealSectionCard.tsx +40 -0
  74. package/src/modules/customers/components/detail/create/DealTipsCard.tsx +26 -0
  75. package/src/modules/customers/components/detail/create/PipelineSelect.tsx +55 -0
  76. package/src/modules/customers/components/detail/create/PipelineStageSelect.tsx +70 -0
  77. package/src/modules/customers/components/detail/create/SuffixInput.tsx +20 -0
  78. package/src/modules/customers/components/detail/create/dealCustomFieldControl.tsx +310 -0
  79. package/src/modules/customers/components/detail/create/dealFormTypes.ts +29 -0
  80. package/src/modules/customers/components/detail/create/dealNumericInput.ts +20 -0
  81. package/src/modules/customers/components/detail/create/useDealCustomFields.ts +118 -0
  82. package/src/modules/customers/components/detail/create/useDealPipelines.ts +80 -0
  83. package/src/modules/customers/components/formConfig.tsx +3 -0
  84. package/src/modules/customers/i18n/de.json +26 -0
  85. package/src/modules/customers/i18n/en.json +26 -0
  86. package/src/modules/customers/i18n/es.json +26 -0
  87. package/src/modules/customers/i18n/pl.json +26 -0
  88. package/src/modules/dictionaries/components/DictionaryEntrySelect.tsx +12 -1
  89. package/src/modules/feature_toggles/lib/feature-flag-check.ts +14 -4
  90. package/src/modules/query_index/subscribers/coverage_refresh.ts +7 -1
  91. package/src/modules/resources/i18n/de.json +1 -0
  92. package/src/modules/resources/i18n/en.json +1 -0
  93. package/src/modules/resources/i18n/es.json +1 -0
  94. package/src/modules/resources/i18n/pl.json +1 -0
  95. package/src/modules/sales/i18n/de.json +2 -0
  96. package/src/modules/sales/i18n/en.json +2 -0
  97. package/src/modules/sales/i18n/es.json +2 -0
  98. package/src/modules/sales/i18n/pl.json +2 -0
  99. package/src/modules/sync_excel/widgets/injection/upload-config/target-options.ts +40 -5
  100. package/src/modules/workflows/components/WorkflowGraph.tsx +39 -235
  101. package/src/modules/workflows/components/WorkflowGraphImpl.tsx +233 -0
@@ -0,0 +1,70 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import {
5
+ Select,
6
+ SelectContent,
7
+ SelectItem,
8
+ SelectTrigger,
9
+ SelectValue,
10
+ } from '@open-mercato/ui/primitives/select'
11
+ import type { PipelineStageOption } from './useDealPipelines'
12
+
13
+ export type PipelineStageSelectProps = {
14
+ id?: string
15
+ stages: PipelineStageOption[]
16
+ value?: string | null
17
+ onChange: (id: string) => void
18
+ disabled?: boolean
19
+ placeholder: string
20
+ formatCount: (position: number, total: number) => string
21
+ }
22
+
23
+ export function PipelineStageSelect({
24
+ id,
25
+ stages,
26
+ value,
27
+ onChange,
28
+ disabled = false,
29
+ placeholder,
30
+ formatCount,
31
+ }: PipelineStageSelectProps) {
32
+ const selectedIndex = React.useMemo(
33
+ () => (value ? stages.findIndex((stage) => stage.id === value) : -1),
34
+ [stages, value],
35
+ )
36
+ const selectedStage = selectedIndex >= 0 ? stages[selectedIndex] : null
37
+
38
+ return (
39
+ <Select
40
+ value={typeof value === 'string' && value ? value : undefined}
41
+ onValueChange={(next) => onChange(next ?? '')}
42
+ disabled={disabled || !stages.length}
43
+ >
44
+ <SelectTrigger id={id} size="default">
45
+ <SelectValue placeholder={placeholder}>
46
+ {selectedStage ? (
47
+ <span className="flex min-w-0 items-center gap-2 truncate">
48
+ <span className="truncate">{selectedStage.label}</span>
49
+ <span className="text-muted-foreground">
50
+ {formatCount(selectedIndex + 1, stages.length)}
51
+ </span>
52
+ </span>
53
+ ) : null}
54
+ </SelectValue>
55
+ </SelectTrigger>
56
+ <SelectContent>
57
+ {stages.map((stage, index) => (
58
+ <SelectItem key={stage.id} value={stage.id}>
59
+ <span className="truncate">{stage.label}</span>
60
+ <span className="text-muted-foreground">
61
+ {formatCount(index + 1, stages.length)}
62
+ </span>
63
+ </SelectItem>
64
+ ))}
65
+ </SelectContent>
66
+ </Select>
67
+ )
68
+ }
69
+
70
+ export default PipelineStageSelect
@@ -0,0 +1,20 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Input } from '@open-mercato/ui/primitives/input'
5
+
6
+ export type SuffixInputProps = React.ComponentPropsWithoutRef<typeof Input> & { suffix: string }
7
+
8
+ export const SuffixInput = React.forwardRef<HTMLInputElement, SuffixInputProps>(
9
+ ({ suffix, ...props }, ref) => {
10
+ return (
11
+ <Input
12
+ ref={ref}
13
+ rightIcon={<span className="text-sm font-medium text-muted-foreground">{suffix}</span>}
14
+ {...props}
15
+ />
16
+ )
17
+ }
18
+ )
19
+
20
+ SuffixInput.displayName = 'SuffixInput'
@@ -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
  }