@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,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
|