@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
@@ -1,13 +1,9 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
- import { useRouter, useSearchParams } from 'next/navigation'
4
+ import { useSearchParams } from 'next/navigation'
5
5
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
6
- import { flash } from '@open-mercato/ui/backend/FlashMessages'
7
- import { createCrud } from '@open-mercato/ui/backend/utils/crud'
8
- import { useT } from '@open-mercato/shared/lib/i18n/context'
9
- import { DealForm, type DealFormSubmitPayload } from '../../../../components/detail/DealForm'
10
- import { useCurrencyDictionary } from '../../../../components/detail/hooks/useCurrencyDictionary'
6
+ import { CreateDealForm } from '../../../../components/detail/create/CreateDealForm'
11
7
 
12
8
  const DEFAULT_RETURN_TO = '/backend/customers/deals'
13
9
 
@@ -24,73 +20,16 @@ function resolveReturnTo(value: string | null | undefined): string {
24
20
  }
25
21
 
26
22
  export default function CreateDealPage() {
27
- const t = useT()
28
- const router = useRouter()
29
23
  const searchParams = useSearchParams()
30
24
  const returnTo = React.useMemo(
31
25
  () => resolveReturnTo(searchParams?.get('returnTo') ?? null),
32
26
  [searchParams],
33
27
  )
34
- const [isSubmitting, setIsSubmitting] = React.useState(false)
35
- useCurrencyDictionary()
36
-
37
- const handleCancel = React.useCallback(() => {
38
- router.push(returnTo)
39
- }, [router, returnTo])
40
-
41
- const handleSubmit = React.useCallback(
42
- async ({ base, custom }: DealFormSubmitPayload) => {
43
- if (isSubmitting) return
44
- setIsSubmitting(true)
45
- try {
46
- const payload: Record<string, unknown> = {
47
- title: base.title,
48
- status: base.status ?? undefined,
49
- pipelineStage: base.pipelineStage ?? undefined,
50
- pipelineId: base.pipelineId ?? undefined,
51
- pipelineStageId: base.pipelineStageId ?? undefined,
52
- valueAmount: typeof base.valueAmount === 'number' ? base.valueAmount : undefined,
53
- valueCurrency: base.valueCurrency ?? undefined,
54
- probability: typeof base.probability === 'number' ? base.probability : undefined,
55
- expectedCloseAt: base.expectedCloseAt ?? undefined,
56
- description: base.description ?? undefined,
57
- personIds: Array.isArray(base.personIds) && base.personIds.length ? base.personIds : undefined,
58
- companyIds: Array.isArray(base.companyIds) && base.companyIds.length ? base.companyIds : undefined,
59
- }
60
- if (Object.keys(custom).length) payload.customFields = custom
61
-
62
- await createCrud('customers/deals', payload, {
63
- errorMessage: t('customers.deals.create.error', 'Failed to create deal.'),
64
- })
65
- flash(t('customers.people.detail.deals.success', 'Deal created.'), 'success')
66
- router.push(returnTo)
67
- } catch (err) {
68
- const message =
69
- err instanceof Error
70
- ? err.message
71
- : t('customers.deals.create.error', 'Failed to create deal.')
72
- flash(message, 'error')
73
- throw err instanceof Error ? err : new Error(message)
74
- } finally {
75
- setIsSubmitting(false)
76
- }
77
- },
78
- [isSubmitting, returnTo, router, t],
79
- )
80
28
 
81
29
  return (
82
30
  <Page>
83
31
  <PageBody>
84
- <DealForm
85
- mode="create"
86
- onSubmit={handleSubmit}
87
- onCancel={handleCancel}
88
- isSubmitting={isSubmitting}
89
- submitLabel={t('customers.deals.create.submit', 'Create deal')}
90
- embedded={false}
91
- title={t('customers.deals.create.title', 'Create deal')}
92
- backHref={returnTo}
93
- />
32
+ <CreateDealForm returnTo={returnTo} />
94
33
  </PageBody>
95
34
  </Page>
96
35
  )
@@ -177,6 +177,8 @@ const schema = z.object({
177
177
  companyIds: z.array(z.string().trim().min(1)).optional(),
178
178
  }).passthrough()
179
179
 
180
+ export const dealFormSchema = schema
181
+
180
182
  import { toDateInputValue as toDateInputValueOrNull } from '@open-mercato/shared/lib/date/format'
181
183
 
182
184
  function toDateInputValue(value: string | null | undefined): string {
@@ -0,0 +1,254 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { Briefcase, Save } from 'lucide-react'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
8
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
9
+ import { createCrud } from '@open-mercato/ui/backend/utils/crud'
10
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
11
+ import { FormHeader } from '@open-mercato/ui/backend/forms'
12
+ import { Button } from '@open-mercato/ui/primitives/button'
13
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
14
+ import { dealFormSchema } from '../DealForm'
15
+ import { createDictionarySelectLabels } from '../utils'
16
+ import { DealSectionCard } from './DealSectionCard'
17
+ import { DealDetailsFields } from './DealDetailsFields'
18
+ import { DealAssociationsSection } from './DealAssociationsSection'
19
+ import { DealCreateSidebar } from './DealCreateSidebar'
20
+ import { useDealPipelines } from './useDealPipelines'
21
+ import { useDealCustomFields } from './useDealCustomFields'
22
+ import { EMPTY_VALUES, type BaseValues } from './dealFormTypes'
23
+
24
+ const CONTEXT_ID = 'customers.deals.create'
25
+ const DEAL_ENTITY_ID = 'customers:customer_deal'
26
+ const CUSTOM_FIELDS_MANAGE_HREF = `/backend/entities/system/${encodeURIComponent(DEAL_ENTITY_ID)}`
27
+
28
+ export type CreateDealFormProps = {
29
+ returnTo: string
30
+ }
31
+
32
+ export function CreateDealForm({ returnTo }: CreateDealFormProps) {
33
+ const t = useT()
34
+ const router = useRouter()
35
+ const tr = React.useCallback(
36
+ (key: string, fallback: string, params?: Record<string, string | number>) =>
37
+ translateWithFallback(t, key, fallback, params),
38
+ [t],
39
+ )
40
+
41
+ const [values, setValues] = React.useState<BaseValues>(EMPTY_VALUES)
42
+ const [errors, setErrors] = React.useState<Record<string, string>>({})
43
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
44
+
45
+ const { pipelines, stages, loadStages } = useDealPipelines()
46
+ const {
47
+ customValues,
48
+ customFieldsLoaded,
49
+ customCount,
50
+ handleCustomChange,
51
+ handleCustomAttributesLoaded,
52
+ validateCustomFields,
53
+ collectNormalizedCustomValues,
54
+ } = useDealCustomFields(tr)
55
+
56
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
57
+ formId: string
58
+ resourceKind: string
59
+ retryLastMutation: () => Promise<boolean>
60
+ }>({
61
+ contextId: CONTEXT_ID,
62
+ blockedMessage: tr('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
63
+ })
64
+
65
+ const statusLabels = React.useMemo(
66
+ () => createDictionarySelectLabels('deal-statuses', (key, fallback) => tr(key, fallback ?? key)),
67
+ [tr],
68
+ )
69
+
70
+ const patch = React.useCallback((partial: Partial<BaseValues>) => {
71
+ setValues((current) => ({ ...current, ...partial }))
72
+ }, [])
73
+
74
+ const handlePipelineChange = React.useCallback(
75
+ (id: string) => {
76
+ patch({ pipelineId: id, pipelineStageId: '' })
77
+ // loadStages resets stages to [] on failure; the rejection is intentionally ignored here.
78
+ loadStages(id).catch(() => {})
79
+ },
80
+ [loadStages, patch],
81
+ )
82
+
83
+ const handleCancel = React.useCallback(() => {
84
+ router.push(returnTo)
85
+ }, [returnTo, router])
86
+
87
+ const handleSubmit = React.useCallback(async () => {
88
+ if (isSubmitting) return
89
+ if (!customFieldsLoaded) {
90
+ flash(tr('customers.deals.create.sections.custom.loading', 'Loading custom fields...'), 'error')
91
+ return
92
+ }
93
+ const merged = { ...values, ...customValues }
94
+ const parsed = dealFormSchema.safeParse(merged)
95
+ if (!parsed.success) {
96
+ const fieldErrors: Record<string, string> = {}
97
+ for (const issue of parsed.error.issues) {
98
+ const key = typeof issue.path[0] === 'string' ? issue.path[0] : undefined
99
+ if (key && !fieldErrors[key]) fieldErrors[key] = tr(issue.message, issue.message)
100
+ }
101
+ setErrors(fieldErrors)
102
+ const firstMessage = Object.values(fieldErrors)[0]
103
+ if (firstMessage) flash(firstMessage, 'error')
104
+ return
105
+ }
106
+
107
+ const customFieldErrors = validateCustomFields(merged)
108
+ if (Object.keys(customFieldErrors).length) {
109
+ setErrors(customFieldErrors)
110
+ const firstMessage = Object.values(customFieldErrors)[0]
111
+ if (firstMessage) flash(firstMessage, 'error')
112
+ return
113
+ }
114
+
115
+ setErrors({})
116
+ setIsSubmitting(true)
117
+ try {
118
+ const data = parsed.data
119
+ const expectedCloseAt =
120
+ data.expectedCloseAt && data.expectedCloseAt.length
121
+ ? new Date(data.expectedCloseAt).toISOString()
122
+ : undefined
123
+ const payload: Record<string, unknown> = {
124
+ title: data.title,
125
+ status: data.status || undefined,
126
+ pipelineId: data.pipelineId || undefined,
127
+ pipelineStageId: data.pipelineStageId || undefined,
128
+ valueAmount: typeof data.valueAmount === 'number' ? data.valueAmount : undefined,
129
+ valueCurrency: data.valueCurrency || undefined,
130
+ probability: typeof data.probability === 'number' ? data.probability : undefined,
131
+ expectedCloseAt,
132
+ description: data.description && data.description.length ? data.description : undefined,
133
+ personIds: values.personIds.length ? values.personIds : undefined,
134
+ companyIds: values.companyIds.length ? values.companyIds : undefined,
135
+ }
136
+ const custom = collectNormalizedCustomValues(merged)
137
+ if (Object.keys(custom).length) payload.customFields = custom
138
+
139
+ await runMutation({
140
+ operation: () =>
141
+ createCrud('customers/deals', payload, {
142
+ errorMessage: tr('customers.deals.create.error', 'Failed to create deal.'),
143
+ }),
144
+ context: { formId: CONTEXT_ID, resourceKind: 'customers.deal', retryLastMutation },
145
+ mutationPayload: payload,
146
+ })
147
+ flash(tr('customers.people.detail.deals.success', 'Deal created.'), 'success')
148
+ router.push(returnTo)
149
+ } catch (err) {
150
+ const message = err instanceof Error ? err.message : tr('customers.deals.create.error', 'Failed to create deal.')
151
+ flash(message, 'error')
152
+ } finally {
153
+ setIsSubmitting(false)
154
+ }
155
+ }, [
156
+ collectNormalizedCustomValues,
157
+ customFieldsLoaded,
158
+ customValues,
159
+ isSubmitting,
160
+ retryLastMutation,
161
+ returnTo,
162
+ router,
163
+ runMutation,
164
+ tr,
165
+ validateCustomFields,
166
+ values,
167
+ ])
168
+
169
+ const onKeyDown = React.useCallback(
170
+ (event: React.KeyboardEvent<HTMLFormElement>) => {
171
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
172
+ event.preventDefault()
173
+ handleSubmit()
174
+ }
175
+ },
176
+ [handleSubmit],
177
+ )
178
+
179
+ const onFormSubmit = React.useCallback(
180
+ (event: React.FormEvent<HTMLFormElement>) => {
181
+ event.preventDefault()
182
+ handleSubmit()
183
+ },
184
+ [handleSubmit],
185
+ )
186
+
187
+ const cancelLabel = tr('customers.deals.create.cancel', 'Cancel')
188
+ const submitLabel = tr('customers.deals.create.submit', 'Create deal')
189
+ const submitDisabled = !customFieldsLoaded
190
+
191
+ return (
192
+ <form className="mx-auto max-w-screen-2xl" onKeyDown={onKeyDown} onSubmit={onFormSubmit} noValidate>
193
+ <FormHeader
194
+ backHref={returnTo}
195
+ backLabel={tr('customers.deals.create.back', 'Back to deals')}
196
+ />
197
+
198
+ <div className="mt-6 grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_330px]">
199
+ <div className="space-y-4">
200
+ <DealSectionCard
201
+ icon={Briefcase}
202
+ title={tr('customers.deals.create.title', 'Create deal')}
203
+ subtitle={tr('customers.deals.create.sections.details.subtitle', 'Core opportunity info')}
204
+ actions={
205
+ <>
206
+ <Button type="button" variant="outline" onClick={handleCancel} disabled={isSubmitting}>
207
+ {cancelLabel}
208
+ </Button>
209
+ <Button type="button" onClick={handleSubmit} disabled={isSubmitting || submitDisabled}>
210
+ {isSubmitting ? <Spinner className="size-4" /> : <Save className="size-4" />}
211
+ {submitLabel}
212
+ </Button>
213
+ </>
214
+ }
215
+ >
216
+ <DealDetailsFields
217
+ values={values}
218
+ errors={errors}
219
+ isSubmitting={isSubmitting}
220
+ patch={patch}
221
+ onPipelineChange={handlePipelineChange}
222
+ pipelines={pipelines}
223
+ stages={stages}
224
+ statusLabels={statusLabels}
225
+ tr={tr}
226
+ />
227
+ </DealSectionCard>
228
+
229
+ <DealAssociationsSection
230
+ tr={tr}
231
+ personIds={values.personIds}
232
+ companyIds={values.companyIds}
233
+ onPeopleChange={(next) => patch({ personIds: next })}
234
+ onCompaniesChange={(next) => patch({ companyIds: next })}
235
+ disabled={isSubmitting}
236
+ />
237
+ </div>
238
+
239
+ <DealCreateSidebar
240
+ tr={tr}
241
+ customValues={customValues}
242
+ onCustomChange={handleCustomChange}
243
+ errors={errors}
244
+ disabled={isSubmitting}
245
+ customCount={customCount}
246
+ manageHref={CUSTOM_FIELDS_MANAGE_HREF}
247
+ onCustomLoaded={handleCustomAttributesLoaded}
248
+ />
249
+ </div>
250
+ </form>
251
+ )
252
+ }
253
+
254
+ export default CreateDealForm
@@ -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