@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,253 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Building2, X } from 'lucide-react'
5
+ import { Avatar } from '@open-mercato/ui/primitives/avatar'
6
+ import { Button } from '@open-mercato/ui/primitives/button'
7
+ import { IconButton } from '@open-mercato/ui/primitives/icon-button'
8
+ import { SearchInput } from '@open-mercato/ui/primitives/search-input'
9
+ import { useDealAssociationLookups } from '../DealForm'
10
+
11
+ type AssociationOption = {
12
+ id: string
13
+ label: string
14
+ subtitle?: string | null
15
+ }
16
+
17
+ export type DealAssociationsFieldProps = {
18
+ id?: string
19
+ kind: 'people' | 'companies'
20
+ value: string[]
21
+ onChange: (next: string[]) => void
22
+ disabled?: boolean
23
+ labels: {
24
+ placeholder: string
25
+ empty: string
26
+ loading: string
27
+ noResults: string
28
+ remove: string
29
+ error: string
30
+ }
31
+ }
32
+
33
+ function sanitizeIdList(input: unknown): string[] {
34
+ if (!Array.isArray(input)) return []
35
+ const set = new Set<string>()
36
+ input.forEach((candidate) => {
37
+ if (typeof candidate !== 'string') return
38
+ const trimmed = candidate.trim()
39
+ if (!trimmed.length) return
40
+ set.add(trimmed)
41
+ })
42
+ return Array.from(set)
43
+ }
44
+
45
+ export function DealAssociationsField({
46
+ id,
47
+ kind,
48
+ value,
49
+ onChange,
50
+ disabled = false,
51
+ labels,
52
+ }: DealAssociationsFieldProps) {
53
+ const lookups = useDealAssociationLookups()
54
+ const search = kind === 'people' ? lookups.searchPeople : lookups.searchCompanies
55
+ const fetchByIds = kind === 'people' ? lookups.fetchPeopleByIds : lookups.fetchCompaniesByIds
56
+
57
+ const [input, setInput] = React.useState('')
58
+ const [suggestions, setSuggestions] = React.useState<AssociationOption[]>([])
59
+ const [cache, setCache] = React.useState<Map<string, AssociationOption>>(() => new Map())
60
+ const [loading, setLoading] = React.useState(false)
61
+ const [error, setError] = React.useState<string | null>(null)
62
+
63
+ const normalizedValue = React.useMemo(() => sanitizeIdList(value), [value])
64
+
65
+ React.useEffect(() => {
66
+ if (!normalizedValue.length) return
67
+ const missing = normalizedValue.filter((id) => !cache.has(id))
68
+ if (!missing.length) return
69
+ let cancelled = false
70
+ ;(async () => {
71
+ try {
72
+ const entries = await fetchByIds(missing)
73
+ if (cancelled) return
74
+ setCache((prev) => {
75
+ const next = new Map(prev)
76
+ entries.forEach((entry) => {
77
+ if (entry?.id) next.set(entry.id, entry)
78
+ })
79
+ return next
80
+ })
81
+ } catch {
82
+ if (!cancelled) setError(labels.error)
83
+ }
84
+ })().catch(() => {
85
+ // The inner try/catch already surfaces failures via setError; this guards the IIFE promise only.
86
+ })
87
+ return () => {
88
+ cancelled = true
89
+ }
90
+ }, [cache, fetchByIds, labels.error, normalizedValue])
91
+
92
+ React.useEffect(() => {
93
+ const query = input.trim()
94
+ // Only query once the operator starts typing. Searching on an empty string
95
+ // returns the first page of *every* person/company in the tenant — useless as
96
+ // a suggestion list and a performance trap at scale (thousands of records).
97
+ if (disabled || query.length === 0) {
98
+ setLoading(false)
99
+ setSuggestions([])
100
+ return
101
+ }
102
+ let cancelled = false
103
+ const handler = window.setTimeout(async () => {
104
+ setLoading(true)
105
+ try {
106
+ const results = await search(query)
107
+ if (cancelled) return
108
+ setSuggestions(results)
109
+ setCache((prev) => {
110
+ const next = new Map(prev)
111
+ results.forEach((entry) => {
112
+ if (entry?.id) next.set(entry.id, entry)
113
+ })
114
+ return next
115
+ })
116
+ setError(null)
117
+ } catch {
118
+ if (!cancelled) {
119
+ setError(labels.error)
120
+ setSuggestions([])
121
+ }
122
+ } finally {
123
+ if (!cancelled) setLoading(false)
124
+ }
125
+ }, 200)
126
+ return () => {
127
+ cancelled = true
128
+ window.clearTimeout(handler)
129
+ }
130
+ }, [disabled, input, labels.error, search])
131
+
132
+ const filteredSuggestions = React.useMemo(
133
+ () => suggestions.filter((option) => !normalizedValue.includes(option.id)),
134
+ [normalizedValue, suggestions],
135
+ )
136
+
137
+ const selectedOptions = React.useMemo(
138
+ () => normalizedValue.map((id) => cache.get(id) ?? { id, label: id }),
139
+ [cache, normalizedValue],
140
+ )
141
+
142
+ const addOption = React.useCallback(
143
+ (option: AssociationOption) => {
144
+ if (!option?.id) return
145
+ if (normalizedValue.includes(option.id)) return
146
+ onChange([...normalizedValue, option.id])
147
+ setCache((prev) => {
148
+ const next = new Map(prev)
149
+ next.set(option.id, option)
150
+ return next
151
+ })
152
+ setInput('')
153
+ setSuggestions([])
154
+ },
155
+ [normalizedValue, onChange],
156
+ )
157
+
158
+ const removeOption = React.useCallback(
159
+ (id: string) => {
160
+ onChange(normalizedValue.filter((candidate) => candidate !== id))
161
+ },
162
+ [normalizedValue, onChange],
163
+ )
164
+
165
+ const renderLeading = React.useCallback(
166
+ (option: AssociationOption) =>
167
+ kind === 'people' ? (
168
+ <Avatar label={option.label} size="xs" />
169
+ ) : (
170
+ <Building2 className="size-3.5 text-muted-foreground" aria-hidden="true" />
171
+ ),
172
+ [kind],
173
+ )
174
+
175
+ return (
176
+ <div className="space-y-2">
177
+ <div className="flex flex-col gap-2 rounded-md border border-input bg-background p-2">
178
+ {selectedOptions.length ? (
179
+ <div className="flex flex-wrap gap-2">
180
+ {selectedOptions.map((option) => (
181
+ <span
182
+ key={option.id}
183
+ className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs text-foreground"
184
+ >
185
+ {renderLeading(option)}
186
+ <span className="truncate">{option.label}</span>
187
+ <IconButton
188
+ type="button"
189
+ variant="ghost"
190
+ size="xs"
191
+ aria-label={`${labels.remove} ${option.label}`}
192
+ onClick={() => removeOption(option.id)}
193
+ disabled={disabled}
194
+ >
195
+ <X className="size-3" />
196
+ </IconButton>
197
+ </span>
198
+ ))}
199
+ </div>
200
+ ) : null}
201
+ <SearchInput
202
+ id={id}
203
+ size="default"
204
+ value={input}
205
+ onChange={setInput}
206
+ placeholder={labels.placeholder}
207
+ disabled={disabled}
208
+ onKeyDown={(event) => {
209
+ if (event.key === 'Enter') {
210
+ event.preventDefault()
211
+ const nextOption = filteredSuggestions[0]
212
+ if (nextOption) addOption(nextOption)
213
+ } else if (event.key === 'Backspace' && !input.length && normalizedValue.length) {
214
+ removeOption(normalizedValue[normalizedValue.length - 1])
215
+ }
216
+ }}
217
+ />
218
+ </div>
219
+ {loading ? <div className="text-xs text-muted-foreground">{labels.loading}</div> : null}
220
+ {!loading && input.trim().length > 0 && filteredSuggestions.length ? (
221
+ <div className="flex max-h-72 flex-col gap-1 overflow-y-auto rounded-md border border-border bg-popover p-1 shadow-md">
222
+ {filteredSuggestions.slice(0, 8).map((option) => (
223
+ <Button
224
+ key={option.id}
225
+ type="button"
226
+ variant="ghost"
227
+ size="sm"
228
+ className="h-auto justify-start px-2 py-1 text-left font-normal"
229
+ onMouseDown={(event) => event.preventDefault()}
230
+ onClick={() => addOption(option)}
231
+ disabled={disabled}
232
+ aria-label={option.label}
233
+ >
234
+ {renderLeading(option)}
235
+ <span className="flex min-w-0 flex-col items-start">
236
+ <span className="truncate text-sm">{option.label}</span>
237
+ {option.subtitle ? (
238
+ <span className="truncate text-xs text-muted-foreground">{option.subtitle}</span>
239
+ ) : null}
240
+ </span>
241
+ </Button>
242
+ ))}
243
+ </div>
244
+ ) : null}
245
+ {!loading && input.trim().length > 0 && !filteredSuggestions.length ? (
246
+ <div className="text-xs text-muted-foreground">{labels.noResults}</div>
247
+ ) : null}
248
+ {error ? <div className="text-xs text-status-error-text">{error}</div> : null}
249
+ </div>
250
+ )
251
+ }
252
+
253
+ export default DealAssociationsField
@@ -0,0 +1,72 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Users } from 'lucide-react'
5
+ import { DealSectionCard } from './DealSectionCard'
6
+ import { DealFormField } from './DealFormField'
7
+ import { DealAssociationsField } from './DealAssociationsField'
8
+ import type { Translate } from './dealFormTypes'
9
+
10
+ export type DealAssociationsSectionProps = {
11
+ tr: Translate
12
+ personIds: string[]
13
+ companyIds: string[]
14
+ onPeopleChange: (next: string[]) => void
15
+ onCompaniesChange: (next: string[]) => void
16
+ disabled: boolean
17
+ }
18
+
19
+ export function DealAssociationsSection({
20
+ tr,
21
+ personIds,
22
+ companyIds,
23
+ onPeopleChange,
24
+ onCompaniesChange,
25
+ disabled,
26
+ }: DealAssociationsSectionProps) {
27
+ const peopleLabels = {
28
+ placeholder: tr('customers.deals.create.associations.peoplePlaceholder', 'Search people by name or email…'),
29
+ empty: tr('customers.deals.form.people.empty', 'No people linked yet.'),
30
+ loading: tr('customers.deals.form.people.loading', 'Searching people…'),
31
+ noResults: tr('customers.deals.form.people.noResults', 'No people match your search.'),
32
+ remove: tr('customers.deals.form.assignees.remove', 'Remove'),
33
+ error: tr('customers.deals.form.people.error', 'Failed to load people.'),
34
+ }
35
+ const companyLabels = {
36
+ placeholder: tr('customers.deals.create.associations.companiesPlaceholder', 'Search companies by name or domain…'),
37
+ empty: tr('customers.deals.form.companies.empty', 'No companies linked yet.'),
38
+ loading: tr('customers.deals.form.companies.loading', 'Searching companies…'),
39
+ noResults: tr('customers.deals.form.companies.noResults', 'No companies match your search.'),
40
+ remove: tr('customers.deals.form.assignees.remove', 'Remove'),
41
+ error: tr('customers.deals.form.companies.error', 'Failed to load companies.'),
42
+ }
43
+
44
+ return (
45
+ <DealSectionCard
46
+ icon={Users}
47
+ title={tr('customers.deals.create.sections.associations.title', 'Associations')}
48
+ subtitle={tr('customers.deals.create.sections.associations.subtitle', 'Link people and companies to this deal')}
49
+ >
50
+ <DealFormField fieldId="personIds" label={tr('customers.people.detail.deals.fields.people', 'People')}>
51
+ <DealAssociationsField
52
+ kind="people"
53
+ value={personIds}
54
+ onChange={onPeopleChange}
55
+ disabled={disabled}
56
+ labels={peopleLabels}
57
+ />
58
+ </DealFormField>
59
+ <DealFormField fieldId="companyIds" label={tr('customers.people.detail.deals.fields.companies', 'Companies')}>
60
+ <DealAssociationsField
61
+ kind="companies"
62
+ value={companyIds}
63
+ onChange={onCompaniesChange}
64
+ disabled={disabled}
65
+ labels={companyLabels}
66
+ />
67
+ </DealFormField>
68
+ </DealSectionCard>
69
+ )
70
+ }
71
+
72
+ export default DealAssociationsSection
@@ -0,0 +1,79 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Sparkles } from 'lucide-react'
5
+ import { DealSectionCard } from './DealSectionCard'
6
+ import { DealTipsCard } from './DealTipsCard'
7
+ import {
8
+ DealCustomAttributes,
9
+ type DealCustomAttributesLoadState,
10
+ } from './DealCustomAttributes'
11
+ import type { Translate } from './dealFormTypes'
12
+
13
+ export type DealCreateSidebarProps = {
14
+ tr: Translate
15
+ customValues: Record<string, unknown>
16
+ onCustomChange: (key: string, value: unknown) => void
17
+ errors: Record<string, string>
18
+ disabled: boolean
19
+ customCount: number
20
+ manageHref: string
21
+ onCustomLoaded: (state: DealCustomAttributesLoadState) => void
22
+ }
23
+
24
+ export function DealCreateSidebar({
25
+ tr,
26
+ customValues,
27
+ onCustomChange,
28
+ errors,
29
+ disabled,
30
+ customCount,
31
+ manageHref,
32
+ onCustomLoaded,
33
+ }: DealCreateSidebarProps) {
34
+ return (
35
+ <div className="space-y-4">
36
+ <DealSectionCard
37
+ icon={Sparkles}
38
+ title={tr('customers.deals.create.sections.custom.title', 'Custom attributes')}
39
+ subtitle={tr('customers.deals.create.sections.custom.subtitle', '{count} fields defined for this tenant', {
40
+ count: customCount,
41
+ })}
42
+ >
43
+ <DealCustomAttributes
44
+ values={customValues}
45
+ onChange={onCustomChange}
46
+ errors={errors}
47
+ disabled={disabled}
48
+ manageHref={manageHref}
49
+ labels={{
50
+ manage: tr('customers.deals.create.sections.custom.manage', 'Manage fields'),
51
+ empty: tr('customers.deals.create.sections.custom.empty', 'No custom fields defined for deals yet.'),
52
+ loading: tr('customers.deals.create.sections.custom.loading', 'Loading custom fields…'),
53
+ }}
54
+ onLoaded={onCustomLoaded}
55
+ />
56
+ </DealSectionCard>
57
+
58
+ <DealTipsCard
59
+ title={tr('customers.deals.create.tips.title', 'Tips for better deals')}
60
+ tips={[
61
+ tr(
62
+ 'customers.deals.create.tips.item1',
63
+ 'Use the company name + short deliverable format in the title (e.g. "Copperleaf — Q3 Renewal")',
64
+ ),
65
+ tr(
66
+ 'customers.deals.create.tips.item2',
67
+ 'Set probability based on pipeline stage: Qual 10-25%, Proposal 30-50%, Negotiation 50-75%, Contract 75-90%',
68
+ ),
69
+ tr(
70
+ 'customers.deals.create.tips.item3',
71
+ 'Link primary decision maker as first person — they get default email CC on activities',
72
+ ),
73
+ ]}
74
+ />
75
+ </div>
76
+ )
77
+ }
78
+
79
+ export default DealCreateSidebar
@@ -0,0 +1,108 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '@open-mercato/shared/lib/utils'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { DictionaryEntrySelect } from '@open-mercato/core/modules/dictionaries/components/DictionaryEntrySelect'
7
+ import { useCurrencyDictionary } from '../hooks/useCurrencyDictionary'
8
+
9
+ const CURRENCY_PRIORITY = ['EUR', 'USD', 'GBP', 'PLN'] as const
10
+
11
+ export type DealCurrencyFieldProps = {
12
+ id?: string
13
+ value: string
14
+ onChange: (code: string) => void
15
+ disabled?: boolean
16
+ }
17
+
18
+ export function DealCurrencyField({ id, value, onChange, disabled = false }: DealCurrencyFieldProps) {
19
+ const t = useT()
20
+ const { data, error: rawError, isLoading, refetch } = useCurrencyDictionary()
21
+ const dictError = rawError
22
+ ? rawError instanceof Error
23
+ ? rawError.message
24
+ : String(rawError)
25
+ : null
26
+
27
+ const resolvedError = React.useMemo(() => {
28
+ if (dictError) return dictError
29
+ if (!isLoading && !data) {
30
+ return t('customers.deals.form.currency.missing', 'Currency dictionary is not configured yet.')
31
+ }
32
+ return null
33
+ }, [data, dictError, isLoading, t])
34
+
35
+ const fetchOptions = React.useCallback(async () => {
36
+ let payload = data ?? null
37
+ if (!payload) {
38
+ try {
39
+ payload = await refetch()
40
+ } catch (err) {
41
+ const message = err instanceof Error ? err.message : String(err ?? '')
42
+ throw new Error(message || t('customers.deals.form.currency.error', 'Failed to load currency dictionary.'))
43
+ }
44
+ }
45
+ if (!payload) {
46
+ throw new Error(t('customers.deals.form.currency.missing', 'Currency dictionary is not configured yet.'))
47
+ }
48
+ const priorityOrder = new Map<string, number>()
49
+ CURRENCY_PRIORITY.forEach((code, index) => priorityOrder.set(code, index))
50
+ const prioritized: { value: string; label: string; color: string | null; icon: string | null }[] = []
51
+ const remainder: { value: string; label: string; color: string | null; icon: string | null }[] = []
52
+ payload.entries.forEach((entry) => {
53
+ const code = entry.value.toUpperCase()
54
+ const label = entry.label && entry.label.length ? `${code} – ${entry.label}` : code
55
+ const option = { value: code, label, color: null, icon: null }
56
+ if (priorityOrder.has(code)) prioritized.push(option)
57
+ else remainder.push(option)
58
+ })
59
+ prioritized.sort((a, b) => priorityOrder.get(a.value)! - priorityOrder.get(b.value)!)
60
+ remainder.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }))
61
+ return [...prioritized, ...remainder]
62
+ }, [data, refetch, t])
63
+
64
+ const labels = React.useMemo(
65
+ () => ({
66
+ placeholder: t('customers.deals.form.currency.placeholder', 'Select currency…'),
67
+ addLabel: t('customers.deals.form.currency.add', 'Add currency'),
68
+ dialogTitle: t('customers.deals.form.currency.dialogTitle', 'Add currency'),
69
+ valueLabel: t('customers.deals.form.currency.valueLabel', 'Currency code'),
70
+ valuePlaceholder: t('customers.deals.form.currency.valuePlaceholder', 'e.g. USD'),
71
+ labelLabel: t('customers.deals.form.currency.labelLabel', 'Label'),
72
+ labelPlaceholder: t('customers.deals.form.currency.labelPlaceholder', 'Display name shown in UI'),
73
+ emptyError: t('customers.deals.form.currency.error.required', 'Currency code is required.'),
74
+ cancelLabel: t('customers.deals.form.currency.cancel', 'Cancel'),
75
+ saveLabel: t('customers.deals.form.currency.save', 'Save'),
76
+ errorLoad: t('customers.deals.form.currency.error', 'Failed to load currency dictionary.'),
77
+ errorSave: t('customers.deals.form.currency.error', 'Failed to load currency dictionary.'),
78
+ loadingLabel: t('customers.deals.form.currency.loading', 'Loading currencies…'),
79
+ manageTitle: t('customers.deals.form.currency.manage', 'Manage currency dictionary'),
80
+ }),
81
+ [t],
82
+ )
83
+
84
+ return (
85
+ <div className="space-y-1">
86
+ <DictionaryEntrySelect
87
+ id={id}
88
+ value={value || undefined}
89
+ onChange={(next) => onChange(next ?? '')}
90
+ fetchOptions={fetchOptions}
91
+ labels={labels}
92
+ manageHref="/backend/config/dictionaries?key=currency"
93
+ allowInlineCreate={false}
94
+ allowAppearance={false}
95
+ selectClassName="w-full"
96
+ disabled={disabled}
97
+ showLabelInput={false}
98
+ />
99
+ {resolvedError ? (
100
+ <p className={cn('text-xs', dictError ? 'text-status-error-text' : 'text-muted-foreground')}>
101
+ {resolvedError}
102
+ </p>
103
+ ) : null}
104
+ </div>
105
+ )
106
+ }
107
+
108
+ export default DealCurrencyField
@@ -0,0 +1,118 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Plus } from 'lucide-react'
5
+ import type { CrudField } from '@open-mercato/ui/backend/CrudForm'
6
+ import type { CustomFieldDefDto } from '@open-mercato/ui/backend/utils/customFieldDefs'
7
+ import { fetchCustomFieldFormStructure } from '@open-mercato/ui/backend/utils/customFieldForms'
8
+ import { LinkButton } from '@open-mercato/ui/primitives/link-button'
9
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
10
+ import { E } from '#generated/entities.ids.generated'
11
+ import { DealCustomFieldControl } from './dealCustomFieldControl'
12
+
13
+ export type DealCustomAttributesLoadState = {
14
+ fields: CrudField[]
15
+ definitions: CustomFieldDefDto[]
16
+ }
17
+
18
+ export type DealCustomAttributesProps = {
19
+ values: Record<string, unknown>
20
+ onChange: (key: string, value: unknown) => void
21
+ errors?: Record<string, string>
22
+ disabled?: boolean
23
+ manageHref: string
24
+ labels: {
25
+ manage: string
26
+ empty: string
27
+ loading: string
28
+ }
29
+ onLoaded?: (state: DealCustomAttributesLoadState) => void
30
+ }
31
+
32
+ function ManageFieldsLink({ href, label }: { href: string; label: string }) {
33
+ return (
34
+ <LinkButton asChild variant="gray" size="sm">
35
+ <a href={href} className="inline-flex items-center gap-1">
36
+ <Plus className="size-3.5" />
37
+ {label}
38
+ </a>
39
+ </LinkButton>
40
+ )
41
+ }
42
+
43
+ export function DealCustomAttributes({
44
+ values,
45
+ onChange,
46
+ errors,
47
+ disabled = false,
48
+ manageHref,
49
+ labels,
50
+ onLoaded,
51
+ }: DealCustomAttributesProps) {
52
+ const [fields, setFields] = React.useState<CrudField[]>([])
53
+ const [isLoading, setIsLoading] = React.useState(true)
54
+ const onLoadedRef = React.useRef(onLoaded)
55
+
56
+ React.useEffect(() => {
57
+ onLoadedRef.current = onLoaded
58
+ }, [onLoaded])
59
+
60
+ React.useEffect(() => {
61
+ let cancelled = false
62
+ setIsLoading(true)
63
+ fetchCustomFieldFormStructure([E.customers.customer_deal])
64
+ .then((result) => {
65
+ if (cancelled) return
66
+ setFields(result.fields)
67
+ setIsLoading(false)
68
+ onLoadedRef.current?.({ fields: result.fields, definitions: result.definitions })
69
+ })
70
+ .catch(() => {
71
+ if (cancelled) return
72
+ setFields([])
73
+ setIsLoading(false)
74
+ onLoadedRef.current?.({ fields: [], definitions: [] })
75
+ })
76
+ return () => {
77
+ cancelled = true
78
+ }
79
+ }, [])
80
+
81
+ if (isLoading) {
82
+ return (
83
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
84
+ <Spinner className="size-4" />
85
+ {labels.loading}
86
+ </div>
87
+ )
88
+ }
89
+
90
+ if (fields.length === 0) {
91
+ return (
92
+ <div className="space-y-3">
93
+ <p className="text-sm text-muted-foreground">{labels.empty}</p>
94
+ <ManageFieldsLink href={manageHref} label={labels.manage} />
95
+ </div>
96
+ )
97
+ }
98
+
99
+ return (
100
+ <div className="space-y-4">
101
+ {fields.map((field) => (
102
+ <DealCustomFieldControl
103
+ key={field.id}
104
+ field={field}
105
+ value={values[field.id]}
106
+ onChange={(next) => onChange(field.id, next)}
107
+ error={errors?.[field.id]}
108
+ disabled={disabled}
109
+ />
110
+ ))}
111
+ <div className="pt-1">
112
+ <ManageFieldsLink href={manageHref} label={labels.manage} />
113
+ </div>
114
+ </div>
115
+ )
116
+ }
117
+
118
+ export default DealCustomAttributes