@open-mercato/core 0.10.1-develop.7761.1.9dfaf470ed → 0.10.1-develop.7762.1.657bb7236b

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 (80) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js +1 -0
  3. package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js.map +2 -2
  4. package/dist/modules/customers/api/companies/[id]/route.js +3 -2
  5. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  6. package/dist/modules/customers/api/deals/[id]/route.js +11 -12
  7. package/dist/modules/customers/api/deals/[id]/route.js.map +2 -2
  8. package/dist/modules/customers/api/people/[id]/route.js +3 -2
  9. package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
  10. package/dist/modules/customers/backend/customers/companies/create/page.js +6 -0
  11. package/dist/modules/customers/backend/customers/companies/create/page.js.map +2 -2
  12. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +25 -25
  13. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  14. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealInjectedTabs.js +8 -27
  15. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealInjectedTabs.js.map +2 -2
  16. package/dist/modules/customers/backend/customers/deals/[id]/page.js +175 -171
  17. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  18. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +25 -24
  19. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +2 -2
  20. package/dist/modules/customers/backend/customers/people/create/page.js +9 -0
  21. package/dist/modules/customers/backend/customers/people/create/page.js.map +2 -2
  22. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +140 -144
  23. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  24. package/dist/modules/customers/components/detail/CompanyDetailHeader.js +2 -0
  25. package/dist/modules/customers/components/detail/CompanyDetailHeader.js.map +2 -2
  26. package/dist/modules/customers/components/detail/CompanyDetailTabs.js +13 -4
  27. package/dist/modules/customers/components/detail/CompanyDetailTabs.js.map +2 -2
  28. package/dist/modules/customers/components/detail/CreatePersonDialog.js +13 -3
  29. package/dist/modules/customers/components/detail/CreatePersonDialog.js.map +2 -2
  30. package/dist/modules/customers/components/detail/CustomerDetailSidebar.js +29 -0
  31. package/dist/modules/customers/components/detail/CustomerDetailSidebar.js.map +7 -0
  32. package/dist/modules/customers/components/detail/DealDetailHeader.js +4 -2
  33. package/dist/modules/customers/components/detail/DealDetailHeader.js.map +2 -2
  34. package/dist/modules/customers/components/detail/DealDetailTabs.js +9 -1
  35. package/dist/modules/customers/components/detail/DealDetailTabs.js.map +2 -2
  36. package/dist/modules/customers/components/detail/DealForm.js +5 -0
  37. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  38. package/dist/modules/customers/components/detail/PersonDetailHeader.js +2 -0
  39. package/dist/modules/customers/components/detail/PersonDetailHeader.js.map +2 -2
  40. package/dist/modules/customers/components/detail/PersonDetailTabs.js +9 -1
  41. package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
  42. package/dist/modules/customers/components/detail/create/CreateDealForm.js +160 -176
  43. package/dist/modules/customers/components/detail/create/CreateDealForm.js.map +2 -2
  44. package/dist/modules/customers/components/detail/useCustomerInjectedTabs.js +47 -0
  45. package/dist/modules/customers/components/detail/useCustomerInjectedTabs.js.map +7 -0
  46. package/dist/modules/customers/components/formConfig.js +3 -0
  47. package/dist/modules/customers/components/formConfig.js.map +2 -2
  48. package/dist/modules/customers/extension-points.js +14 -4
  49. package/dist/modules/customers/extension-points.js.map +2 -2
  50. package/dist/modules/customers/lib/detailResponseEnrichment.js +38 -0
  51. package/dist/modules/customers/lib/detailResponseEnrichment.js.map +7 -0
  52. package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js +4 -4
  53. package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js.map +2 -2
  54. package/package.json +7 -7
  55. package/src/modules/customer_accounts/widgets/injection/account-status/widget.client.tsx +2 -0
  56. package/src/modules/customers/api/companies/[id]/route.ts +3 -2
  57. package/src/modules/customers/api/deals/[id]/route.ts +12 -12
  58. package/src/modules/customers/api/people/[id]/route.ts +3 -2
  59. package/src/modules/customers/backend/customers/companies/create/page.tsx +6 -0
  60. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +27 -30
  61. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealInjectedTabs.tsx +9 -44
  62. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +7 -3
  63. package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +25 -27
  64. package/src/modules/customers/backend/customers/people/create/page.tsx +9 -0
  65. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +21 -33
  66. package/src/modules/customers/components/detail/CompanyDetailHeader.tsx +3 -0
  67. package/src/modules/customers/components/detail/CompanyDetailTabs.tsx +15 -4
  68. package/src/modules/customers/components/detail/CreatePersonDialog.tsx +15 -4
  69. package/src/modules/customers/components/detail/CustomerDetailSidebar.tsx +41 -0
  70. package/src/modules/customers/components/detail/DealDetailHeader.tsx +4 -1
  71. package/src/modules/customers/components/detail/DealDetailTabs.tsx +11 -1
  72. package/src/modules/customers/components/detail/DealForm.tsx +5 -0
  73. package/src/modules/customers/components/detail/PersonDetailHeader.tsx +3 -0
  74. package/src/modules/customers/components/detail/PersonDetailTabs.tsx +11 -1
  75. package/src/modules/customers/components/detail/create/CreateDealForm.tsx +126 -209
  76. package/src/modules/customers/components/detail/useCustomerInjectedTabs.tsx +64 -0
  77. package/src/modules/customers/components/formConfig.tsx +3 -0
  78. package/src/modules/customers/extension-points.ts +14 -4
  79. package/src/modules/customers/lib/detailResponseEnrichment.ts +53 -0
  80. package/src/modules/integrations/widgets/injection/external-ids/widget.client.tsx +4 -4
@@ -8,10 +8,13 @@ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
8
8
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
9
9
  import { createCrud } from '@open-mercato/ui/backend/utils/crud'
10
10
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
11
+ import { CrudForm, type CrudField, type CrudFormGroup } from '@open-mercato/ui/backend/CrudForm'
11
12
  import { useCurrentUserId } from '@open-mercato/ui/backend/utils/useCurrentUserId'
12
13
  import { FormHeader } from '@open-mercato/ui/backend/forms'
13
14
  import { Button } from '@open-mercato/ui/primitives/button'
14
15
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
16
+ import { E } from '#generated/entities.ids.generated'
17
+ import { extensionPoints } from '../../../extension-points'
15
18
  import { dealFormSchema } from '../DealForm'
16
19
  import { createDictionarySelectLabels } from '../utils'
17
20
  import { DealSectionCard } from './DealSectionCard'
@@ -32,6 +35,20 @@ export type CreateDealFormProps = {
32
35
  initialValues?: Partial<BaseValues>
33
36
  }
34
37
 
38
+ function readCreateValues(values: Record<string, unknown>): BaseValues {
39
+ const result = { ...EMPTY_VALUES }
40
+ for (const key of Object.keys(EMPTY_VALUES) as Array<keyof BaseValues>) {
41
+ if (key === 'personIds' || key === 'companyIds') {
42
+ const raw = values[key]
43
+ result[key] = Array.isArray(raw) ? raw.filter((value): value is string => typeof value === 'string') : []
44
+ } else {
45
+ const raw = values[key]
46
+ result[key] = typeof raw === 'string' || typeof raw === 'number' ? String(raw) : ''
47
+ }
48
+ }
49
+ return result
50
+ }
51
+
35
52
  export function CreateDealForm({ returnTo, initialValues }: CreateDealFormProps) {
36
53
  const t = useT()
37
54
  const router = useRouter()
@@ -40,18 +57,10 @@ export function CreateDealForm({ returnTo, initialValues }: CreateDealFormProps)
40
57
  translateWithFallback(t, key, fallback, params),
41
58
  [t],
42
59
  )
43
-
44
- const [values, setValues] = React.useState<BaseValues>(() => {
45
- const definedSeedEntries = Object.entries(initialValues ?? {}).filter(([, seedValue]) => seedValue !== undefined)
46
- return { ...EMPTY_VALUES, ...Object.fromEntries(definedSeedEntries) }
47
- })
48
- const [errors, setErrors] = React.useState<Record<string, string>>({})
49
- const [isSubmitting, setIsSubmitting] = React.useState(false)
50
-
51
60
  // Default the owner to the current user, matching the pipeline quick-create dialog so a
52
61
  // deal created here does not start unowned (an unowned deal notifies nobody when it closes).
53
- // useCurrentUserId resolves asynchronously, so this seeds once the id arrives and never
54
- // overrides an explicit seed or a choice the user has already made.
62
+ // useCurrentUserId resolves asynchronously; CrudForm merges the late seed without touching
63
+ // an explicit seed or a field the user has already edited.
55
64
  const currentUserId = useCurrentUserId()
56
65
  // Seeds the picker for the self-assigned id, which may not be on the assignable roster
57
66
  // (an admin who is not a staff team member, or any user when the `staff` module is off).
@@ -61,228 +70,136 @@ export function CreateDealForm({ returnTo, initialValues }: CreateDealFormProps)
61
70
  : null),
62
71
  [currentUserId, tr],
63
72
  )
64
- const ownerSeeded = React.useRef(false)
65
- React.useEffect(() => {
66
- if (ownerSeeded.current || !currentUserId) return
67
- ownerSeeded.current = true
68
- if (initialValues?.ownerUserId) return
69
- setValues((current) => (current.ownerUserId ? current : { ...current, ownerUserId: currentUserId }))
70
- }, [currentUserId, initialValues?.ownerUserId])
71
-
73
+ const seedValues = React.useMemo(() => {
74
+ const seeded: BaseValues = {
75
+ ...EMPTY_VALUES,
76
+ ...Object.fromEntries(Object.entries(initialValues ?? {}).filter(([, value]) => value !== undefined)),
77
+ }
78
+ return seeded.ownerUserId || !currentUserId ? seeded : { ...seeded, ownerUserId: currentUserId }
79
+ }, [initialValues, currentUserId])
80
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
81
+ const { runMutation, retryLastMutation } = useGuardedMutation({ contextId: CONTEXT_ID, blockedMessage: tr('ui.forms.flash.saveBlocked', 'Save blocked by validation') })
72
82
  const { pipelines, stages, loadStages } = useDealPipelines()
73
- const {
74
- customValues,
75
- customFieldsLoaded,
76
- customCount,
77
- handleCustomChange,
78
- handleCustomAttributesLoaded,
79
- validateCustomFields,
80
- collectNormalizedCustomValues,
81
- } = useDealCustomFields(tr)
82
-
83
- const { runMutation, retryLastMutation } = useGuardedMutation<{
84
- formId: string
85
- resourceKind: string
86
- retryLastMutation: () => Promise<boolean>
87
- }>({
88
- contextId: CONTEXT_ID,
89
- blockedMessage: tr('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
90
- })
91
-
83
+ const { customFieldsLoaded, customCount, handleCustomAttributesLoaded, collectNormalizedCustomValues } = useDealCustomFields(tr)
92
84
  const statusLabels = React.useMemo(
93
85
  () => createDictionarySelectLabels('deal-statuses', (key, fallback) => tr(key, fallback ?? key)),
94
86
  [tr],
95
87
  )
96
-
97
- const patch = React.useCallback((partial: Partial<BaseValues>) => {
98
- // A user touching the owner before useCurrentUserId resolves must not be overridden by
99
- // the seeding effect below.
100
- if ('ownerUserId' in partial) ownerSeeded.current = true
101
- setValues((current) => ({ ...current, ...partial }))
102
- }, [])
103
-
104
- const handlePipelineChange = React.useCallback(
105
- (id: string) => {
106
- patch({ pipelineId: id, pipelineStageId: '' })
107
- // loadStages resets stages to [] on failure; the rejection is intentionally ignored here.
108
- loadStages(id).catch(() => {})
109
- },
110
- [loadStages, patch],
111
- )
112
-
113
- const handleCancel = React.useCallback(() => {
114
- router.push(returnTo)
115
- }, [returnTo, router])
116
-
117
- const handleSubmit = React.useCallback(async () => {
118
- if (isSubmitting) return
119
- if (!customFieldsLoaded) {
120
- flash(tr('customers.deals.create.sections.custom.loading', 'Loading custom fields...'), 'error')
121
- return
88
+ const handleCancel = React.useCallback(() => router.push(returnTo), [returnTo, router])
89
+ const handleSubmit = React.useCallback(async (values: Record<string, unknown>) => {
90
+ const data = dealFormSchema.parse(values)
91
+ const payload: Record<string, unknown> = {
92
+ title: data.title,
93
+ status: data.status || undefined,
94
+ pipelineId: data.pipelineId || undefined,
95
+ pipelineStageId: data.pipelineStageId || undefined,
96
+ valueAmount: typeof data.valueAmount === 'number' ? data.valueAmount : undefined,
97
+ valueCurrency: data.valueCurrency || undefined,
98
+ probability: typeof data.probability === 'number' ? data.probability : undefined,
99
+ expectedCloseAt: data.expectedCloseAt ? new Date(data.expectedCloseAt).toISOString() : undefined,
100
+ description: data.description || undefined,
101
+ // `null`, not omitted — matching DealForm, so both create surfaces send the same
102
+ // shape. `dealCreateSchema.ownerUserId` is `.optional().nullable()` and
103
+ // createDealCommand maps null to null.
104
+ ownerUserId: data.ownerUserId && data.ownerUserId.length ? data.ownerUserId : null,
105
+ personIds: data.personIds?.length ? data.personIds : undefined,
106
+ companyIds: data.companyIds?.length ? data.companyIds : undefined,
122
107
  }
123
- const merged = { ...values, ...customValues }
124
- const parsed = dealFormSchema.safeParse(merged)
125
- if (!parsed.success) {
126
- const fieldErrors: Record<string, string> = {}
127
- for (const issue of parsed.error.issues) {
128
- const key = typeof issue.path[0] === 'string' ? issue.path[0] : undefined
129
- if (key && !fieldErrors[key]) fieldErrors[key] = tr(issue.message, issue.message)
130
- }
131
- setErrors(fieldErrors)
132
- const firstMessage = Object.values(fieldErrors)[0]
133
- if (firstMessage) flash(firstMessage, 'error')
134
- return
135
- }
136
-
137
- const customFieldErrors = validateCustomFields(merged)
138
- if (Object.keys(customFieldErrors).length) {
139
- setErrors(customFieldErrors)
140
- const firstMessage = Object.values(customFieldErrors)[0]
141
- if (firstMessage) flash(firstMessage, 'error')
142
- return
143
- }
144
-
145
- setErrors({})
108
+ const custom = collectNormalizedCustomValues(values)
109
+ if (Object.keys(custom).length) payload.customFields = custom
146
110
  setIsSubmitting(true)
147
111
  try {
148
- const data = parsed.data
149
- const expectedCloseAt =
150
- data.expectedCloseAt && data.expectedCloseAt.length
151
- ? new Date(data.expectedCloseAt).toISOString()
152
- : undefined
153
- const payload: Record<string, unknown> = {
154
- title: data.title,
155
- status: data.status || undefined,
156
- pipelineId: data.pipelineId || undefined,
157
- pipelineStageId: data.pipelineStageId || undefined,
158
- valueAmount: typeof data.valueAmount === 'number' ? data.valueAmount : undefined,
159
- valueCurrency: data.valueCurrency || undefined,
160
- probability: typeof data.probability === 'number' ? data.probability : undefined,
161
- expectedCloseAt,
162
- description: data.description && data.description.length ? data.description : undefined,
163
- // `null`, not omitted — matching DealForm, so both create surfaces send the same
164
- // shape. `dealCreateSchema.ownerUserId` is `.optional().nullable()` (validators.ts:183)
165
- // and createDealCommand maps null to null (commands/deals.ts:591).
166
- ownerUserId: data.ownerUserId && data.ownerUserId.length ? data.ownerUserId : null,
167
- personIds: values.personIds.length ? values.personIds : undefined,
168
- companyIds: values.companyIds.length ? values.companyIds : undefined,
169
- }
170
- const custom = collectNormalizedCustomValues(merged)
171
- if (Object.keys(custom).length) payload.customFields = custom
172
-
173
- await runMutation({
174
- operation: () =>
175
- createCrud('customers/deals', payload, {
176
- errorMessage: tr('customers.deals.create.error', 'Failed to create deal.'),
177
- }),
112
+ const { result } = await runMutation({
113
+ operation: () => createCrud<{ id?: string; entityId?: string }>('customers/deals', payload, {
114
+ errorMessage: tr('customers.deals.create.error', 'Failed to create deal.'),
115
+ }),
178
116
  context: { formId: CONTEXT_ID, resourceKind: 'customers.deal', retryLastMutation },
179
117
  mutationPayload: payload,
180
118
  })
181
119
  flash(tr('customers.people.detail.deals.success', 'Deal created.'), 'success')
182
- router.push(returnTo)
183
- } catch (err) {
184
- const message = err instanceof Error ? err.message : tr('customers.deals.create.error', 'Failed to create deal.')
185
- flash(message, 'error')
120
+ return { resourceId: result?.id ?? result?.entityId }
186
121
  } finally {
187
122
  setIsSubmitting(false)
188
123
  }
189
- }, [
190
- collectNormalizedCustomValues,
191
- customFieldsLoaded,
192
- customValues,
193
- isSubmitting,
194
- retryLastMutation,
195
- returnTo,
196
- router,
197
- runMutation,
198
- tr,
199
- validateCustomFields,
200
- values,
201
- ])
202
-
203
- const onKeyDown = React.useCallback(
204
- (event: React.KeyboardEvent<HTMLFormElement>) => {
205
- if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
206
- event.preventDefault()
207
- handleSubmit()
208
- }
209
- },
210
- [handleSubmit],
211
- )
212
-
213
- const onFormSubmit = React.useCallback(
214
- (event: React.FormEvent<HTMLFormElement>) => {
215
- event.preventDefault()
216
- handleSubmit()
217
- },
218
- [handleSubmit],
219
- )
220
-
221
- const cancelLabel = tr('customers.deals.create.cancel', 'Cancel')
222
- const submitLabel = tr('customers.deals.create.submit', 'Create deal')
223
- const submitDisabled = !customFieldsLoaded
224
-
225
- return (
226
- <form className="mx-auto max-w-screen-2xl" onKeyDown={onKeyDown} onSubmit={onFormSubmit} noValidate>
227
- <FormHeader
228
- backHref={returnTo}
229
- backLabel={tr('customers.deals.create.back', 'Back to deals')}
230
- />
231
-
232
- <div className="mt-6 grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_330px]">
233
- <div className="space-y-4">
124
+ }, [collectNormalizedCustomValues, tr, runMutation, retryLastMutation])
125
+ const handleSubmitSuccess = React.useCallback(() => {
126
+ router.push(returnTo)
127
+ }, [router, returnTo])
128
+ const fields = React.useMemo<CrudField[]>(() => Object.keys(EMPTY_VALUES).map((id) => ({
129
+ id,
130
+ label: tr(`customers.people.detail.deals.fields.${id}`, id),
131
+ type: 'text',
132
+ required: id === 'title',
133
+ })), [tr])
134
+ const groups = React.useMemo<CrudFormGroup[]>(() => [
135
+ {
136
+ id: 'details',
137
+ bare: true,
138
+ column: 1,
139
+ fields: Object.keys(EMPTY_VALUES).filter((id) => id !== 'personIds' && id !== 'companyIds'),
140
+ component: ({ values, setValue, errors, injectedFields }) => {
141
+ const patch = (partial: Partial<BaseValues>) => {
142
+ for (const [key, value] of Object.entries(partial)) setValue(key, value)
143
+ }
144
+ return (
234
145
  <DealSectionCard
235
146
  icon={Briefcase}
236
147
  title={tr('customers.deals.create.title', 'Create deal')}
237
148
  subtitle={tr('customers.deals.create.sections.details.subtitle', 'Core opportunity info')}
238
- actions={
239
- <>
240
- <Button type="button" variant="outline" onClick={handleCancel} disabled={isSubmitting}>
241
- {cancelLabel}
242
- </Button>
243
- <Button type="button" onClick={handleSubmit} disabled={isSubmitting || submitDisabled}>
244
- {isSubmitting ? <Spinner className="size-4" /> : <Save className="size-4" />}
245
- {submitLabel}
246
- </Button>
247
- </>
248
- }
149
+ actions={<>
150
+ <Button type="button" variant="outline" onClick={handleCancel} disabled={isSubmitting}>{tr('customers.deals.create.cancel', 'Cancel')}</Button>
151
+ <Button type="submit" form={CONTEXT_ID} disabled={isSubmitting || !customFieldsLoaded}>
152
+ {isSubmitting ? <Spinner className="size-4" /> : <Save className="size-4" />}
153
+ {tr('customers.deals.create.submit', 'Create deal')}
154
+ </Button>
155
+ </>}
249
156
  >
250
- <DealDetailsFields
251
- values={values}
252
- errors={errors}
253
- isSubmitting={isSubmitting}
254
- patch={patch}
255
- onPipelineChange={handlePipelineChange}
256
- pipelines={pipelines}
257
- stages={stages}
258
- statusLabels={statusLabels}
259
- ownerInitialOption={ownerInitialOption}
260
- tr={tr}
261
- />
157
+ <DealDetailsFields values={readCreateValues(values)} errors={errors} isSubmitting={isSubmitting} patch={patch}
158
+ onPipelineChange={(pipelineId) => { patch({ pipelineId, pipelineStageId: '' }); void loadStages(pipelineId).catch(() => {}) }}
159
+ pipelines={pipelines} stages={stages} statusLabels={statusLabels} ownerInitialOption={ownerInitialOption} tr={tr} />
160
+ {injectedFields}
262
161
  </DealSectionCard>
162
+ )
163
+ },
164
+ },
165
+ {
166
+ id: 'associations',
167
+ bare: true,
168
+ column: 1,
169
+ fields: ['personIds', 'companyIds'],
170
+ component: ({ values, setValue, injectedFields }) => <>
171
+ <DealAssociationsSection tr={tr} personIds={readCreateValues(values).personIds} companyIds={readCreateValues(values).companyIds}
172
+ onPeopleChange={(next) => setValue('personIds', next)} onCompaniesChange={(next) => setValue('companyIds', next)} disabled={isSubmitting} />
173
+ {injectedFields}
174
+ </>,
175
+ },
176
+ {
177
+ id: 'custom',
178
+ bare: true,
179
+ column: 2,
180
+ component: ({ values, setValue, errors, injectedFields }) => <>
181
+ <DealCreateSidebar tr={tr} customValues={values} onCustomChange={setValue} errors={errors} disabled={isSubmitting}
182
+ customCount={customCount} manageHref={CUSTOM_FIELDS_MANAGE_HREF} onCustomLoaded={handleCustomAttributesLoaded} />
183
+ {injectedFields}
184
+ </>,
185
+ },
186
+ ], [tr, handleCancel, isSubmitting, customFieldsLoaded, loadStages, pipelines, stages, statusLabels, ownerInitialOption, customCount, handleCustomAttributesLoaded])
263
187
 
264
- <DealAssociationsSection
265
- tr={tr}
266
- personIds={values.personIds}
267
- companyIds={values.companyIds}
268
- onPeopleChange={(next) => patch({ personIds: next })}
269
- onCompaniesChange={(next) => patch({ companyIds: next })}
270
- disabled={isSubmitting}
271
- />
272
- </div>
273
-
274
- <DealCreateSidebar
275
- tr={tr}
276
- customValues={customValues}
277
- onCustomChange={handleCustomChange}
278
- errors={errors}
279
- disabled={isSubmitting}
280
- customCount={customCount}
281
- manageHref={CUSTOM_FIELDS_MANAGE_HREF}
282
- onCustomLoaded={handleCustomAttributesLoaded}
283
- />
188
+ return (
189
+ <div className="mx-auto max-w-screen-2xl" onKeyDown={(event) => {
190
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
191
+ event.preventDefault()
192
+ if (!isSubmitting && customFieldsLoaded) document.getElementById(CONTEXT_ID)?.closest('form')?.requestSubmit()
193
+ }
194
+ }}>
195
+ <FormHeader backHref={returnTo} backLabel={tr('customers.deals.create.back', 'Back to deals')} />
196
+ <div className="mt-6">
197
+ <CrudForm<Record<string, unknown>> embedded hideFooterActions formId={CONTEXT_ID}
198
+ entityId="customers.deal" resourceKind="customers.deal" entityIds={[E.customers.customer_deal]}
199
+ injectionSpotId={extensionPoints.hosts.dealForm.spotId} fields={fields} groups={groups}
200
+ initialValues={seedValues} schema={dealFormSchema} onSubmit={handleSubmit} onSubmitSuccess={handleSubmitSuccess} />
284
201
  </div>
285
- </form>
202
+ </div>
286
203
  )
287
204
  }
288
205
 
@@ -0,0 +1,64 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+ import { InjectionSpot, useInjectionWidgets, type LoadedInjectionSpotWidget } from '@open-mercato/ui/backend/injection/InjectionSpot'
6
+
7
+ export type CustomerInjectedTab = {
8
+ id: string
9
+ label: string
10
+ priority: number
11
+ widgets: LoadedInjectionSpotWidget[]
12
+ }
13
+
14
+ export function groupCustomerInjectedTabs(
15
+ widgets: LoadedInjectionSpotWidget[],
16
+ translate: (key: string, fallback: string) => string,
17
+ ): CustomerInjectedTab[] {
18
+ const groups = new Map<string, CustomerInjectedTab>()
19
+ const seen = new Set<string>()
20
+ for (const widget of widgets) {
21
+ if (seen.has(widget.widgetId) || widget.placement?.kind === 'stack') continue
22
+ seen.add(widget.widgetId)
23
+ const id = widget.placement?.groupId ?? widget.widgetId
24
+ const declaredPriority = widget.placement?.priority ?? widget.module.metadata.priority
25
+ const priority = typeof declaredPriority === 'number' ? declaredPriority : 0
26
+ const group = groups.get(id)
27
+ if (group) {
28
+ group.widgets.push(widget)
29
+ group.priority = Math.max(group.priority, priority)
30
+ } else {
31
+ const label = widget.placement?.groupLabel ?? widget.module.metadata.title ?? id
32
+ groups.set(id, { id, label: translate(label, label), priority, widgets: [widget] })
33
+ }
34
+ }
35
+ return Array.from(groups.values()).sort((left, right) => right.priority - left.priority)
36
+ }
37
+
38
+ export function useCustomerInjectedTabs<TData>({
39
+ spotId,
40
+ context,
41
+ data,
42
+ onDataChange,
43
+ nativeTabIds,
44
+ }: {
45
+ spotId: string
46
+ context: unknown
47
+ data: TData
48
+ onDataChange?: (data: TData) => void
49
+ nativeTabIds: readonly string[]
50
+ }) {
51
+ const t = useT()
52
+ const { widgets, loading } = useInjectionWidgets(spotId, { context, triggerOnLoad: true })
53
+ const groups = React.useMemo(() => groupCustomerInjectedTabs(widgets, t), [widgets, t])
54
+ const injectedTabs = React.useMemo(
55
+ () => groups.filter((group) => !nativeTabIds.includes(group.id)),
56
+ [groups, nativeTabIds],
57
+ )
58
+ const injectedTabMap = React.useMemo(() => new Map(groups.map((group) => [group.id, () => (
59
+ <div className="space-y-4">
60
+ <InjectionSpot spotId={spotId} context={context} data={data} onDataChange={onDataChange} widgetsOverride={group.widgets} />
61
+ </div>
62
+ )])), [groups, spotId, context, data, onDataChange])
63
+ return { injectedTabs, injectedTabMap, loading }
64
+ }
@@ -1,5 +1,6 @@
1
1
  "use client"
2
2
 
3
+ import { extractResponseEnricherNamespaces } from '@open-mercato/shared/lib/crud/response-enricher-namespaces'
3
4
  import * as React from 'react'
4
5
  import { z } from 'zod'
5
6
  import Link from 'next/link'
@@ -2189,6 +2190,7 @@ export function mapCompanyOverviewToFormValues(overview: CompanyOverview): Parti
2189
2190
  const rawPhone = overview.company.primaryPhone
2190
2191
  const phoneValue = rawPhone == null ? '' : String(rawPhone)
2191
2192
  return {
2193
+ ...extractResponseEnricherNamespaces(overview.company),
2192
2194
  id: overview.company.id,
2193
2195
  organizationId: overview.company.organizationId ?? null,
2194
2196
  displayName: coerceDisplayName(overview.company.displayName),
@@ -2213,6 +2215,7 @@ export function mapPersonOverviewToFormValues(overview: PersonOverview): Partial
2213
2215
  const rawPhone = overview.person.primaryPhone
2214
2216
  const phoneValue = rawPhone == null ? '' : String(rawPhone)
2215
2217
  return {
2218
+ ...extractResponseEnricherNamespaces(overview.person),
2216
2219
  id: overview.person.id,
2217
2220
  organizationId: overview.person.organizationId ?? null,
2218
2221
  displayName: coerceDisplayName(overview.person.displayName),
@@ -6,11 +6,12 @@ import {
6
6
  injectionExtensionHost,
7
7
  } from '@open-mercato/shared/modules/widgets/extension-points'
8
8
 
9
- const detailHost = (spotId: string, source: string) => injectionExtensionHost({
9
+ const detailHost = (spotId: string, source: string, dataContract?: string) => injectionExtensionHost({
10
10
  family: 'detail',
11
11
  spotId,
12
12
  supported: ['render-widget'],
13
13
  contextContract: 'customers.detail.v1',
14
+ dataContract,
14
15
  source,
15
16
  })
16
17
 
@@ -21,9 +22,9 @@ export const extensionPoints = defineModuleExtensionPoints({
21
22
  dealsTable: dataTableExtensionHost({ tableId: 'customers.deals.list', source: 'backend/customers/deals/page.tsx' }),
22
23
  peopleTable: dataTableExtensionHost({ tableId: 'customers.people.list', source: 'backend/customers/people/page.tsx' }),
23
24
  todosTable: dataTableExtensionHost({ tableId: 'customers.todos.list', source: 'components/CustomerTodosTable.tsx' }),
24
- companyForm: crudFormExtensionHost({ entityId: 'customers.company', spotId: 'crud-form:customers.company', source: 'backend/customers/companies-v2/[id]/page.tsx' }),
25
- dealForm: crudFormExtensionHost({ entityId: 'customers.deal', spotId: 'crud-form:customers.deal', source: 'backend/customers/deals/[id]/page.tsx' }),
26
- personForm: crudFormExtensionHost({ entityId: 'customers.person', spotId: 'crud-form:customers.person', source: 'backend/customers/people-v2/[id]/page.tsx' }),
25
+ companyForm: crudFormExtensionHost({ entityId: 'customers.company', spotId: 'crud-form:customers.company', source: ['backend/customers/companies-v2/[id]/page.tsx', 'backend/customers/companies/create/page.tsx'] }),
26
+ dealForm: crudFormExtensionHost({ entityId: 'customers.deal', spotId: 'crud-form:customers.deal', source: ['backend/customers/deals/[id]/page.tsx', 'components/detail/create/CreateDealForm.tsx', 'backend/customers/deals/pipeline/components/QuickDealDialog.tsx'] }),
27
+ personForm: crudFormExtensionHost({ entityId: 'customers.person', spotId: 'crud-form:customers.person', source: ['backend/customers/people-v2/[id]/page.tsx', 'backend/customers/people/create/page.tsx', 'components/detail/CreatePersonDialog.tsx'] }),
27
28
  calendarEventTypes: injectionExtensionHost({
28
29
  family: 'generic',
29
30
  spotId: 'calendar:customers.event-types',
@@ -36,13 +37,22 @@ export const extensionPoints = defineModuleExtensionPoints({
36
37
  propsContract: 'customers.calendar.event-type-panel.v1',
37
38
  source: 'components/calendar/editor/EventTypePanel.tsx',
38
39
  }),
40
+ companyHeaderActions: detailHost('detail:customers.company:header-actions', 'backend/customers/companies-v2/[id]/page.tsx', 'customers.detail.overview.v1'),
41
+ companySidebar: detailHost('detail:customers.company:sidebar', 'backend/customers/companies-v2/[id]/page.tsx', 'customers.detail.overview.v1'),
42
+ companyTabs: detailHost('detail:customers.company:tabs', 'backend/customers/companies-v2/[id]/page.tsx', 'customers.detail.overview.v1'),
39
43
  companyHeader: detailHost('detail:customers.company:header', 'backend/customers/companies-v2/[id]/page.tsx'),
40
44
  companyStatusBadges: detailHost('detail:customers.company:status-badges', 'backend/customers/companies-v2/[id]/page.tsx'),
41
45
  companyFooter: detailHost('detail:customers.company:footer', 'backend/customers/companies-v2/[id]/page.tsx'),
42
46
  companyLegacyDetails: detailHost('customers.company.detail:details', 'backend/customers/companies/[id]/page.tsx'),
47
+ dealHeaderActions: detailHost('detail:customers.deal:header-actions', 'backend/customers/deals/[id]/page.tsx', 'customers.detail.overview.v1'),
48
+ dealSidebar: detailHost('detail:customers.deal:sidebar', 'backend/customers/deals/[id]/page.tsx', 'customers.detail.overview.v1'),
49
+ dealTabs: detailHost('detail:customers.deal:tabs', 'backend/customers/deals/[id]/hooks/useDealInjectedTabs.tsx', 'customers.detail.overview.v1'),
43
50
  dealHeader: detailHost('detail:customers.deal:header', 'backend/customers/deals/[id]/page.tsx'),
44
51
  dealStatusBadges: detailHost('detail:customers.deal:status-badges', 'backend/customers/deals/[id]/page.tsx'),
45
52
  dealFooter: detailHost('detail:customers.deal:footer', 'backend/customers/deals/[id]/page.tsx'),
53
+ personHeaderActions: detailHost('detail:customers.person:header-actions', 'backend/customers/people-v2/[id]/page.tsx', 'customers.detail.overview.v1'),
54
+ personSidebar: detailHost('detail:customers.person:sidebar', 'backend/customers/people-v2/[id]/page.tsx', 'customers.detail.overview.v1'),
55
+ personTabs: detailHost('detail:customers.person:tabs', 'backend/customers/people-v2/[id]/page.tsx', 'customers.detail.overview.v1'),
46
56
  personHeader: detailHost('detail:customers.person:header', 'backend/customers/people-v2/[id]/page.tsx'),
47
57
  personStatusBadges: detailHost('detail:customers.person:status-badges', 'backend/customers/people-v2/[id]/page.tsx'),
48
58
  personFooter: detailHost('detail:customers.person:footer', 'backend/customers/people-v2/[id]/page.tsx'),
@@ -0,0 +1,53 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
3
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { getEnrichersForEntity } from '@open-mercato/shared/lib/crud/enricher-registry'
5
+ import { applyResponseEnricherToRecord } from '@open-mercato/shared/lib/crud/enricher-runner'
6
+ import { extractResponseEnricherNamespaces } from '@open-mercato/shared/lib/crud/response-enricher-namespaces'
7
+ import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
8
+
9
+ type DetailRecordKey = 'person' | 'company' | 'deal'
10
+
11
+ type DetailEnrichmentContext = {
12
+ auth: NonNullable<AuthContext>
13
+ container: AppContainer
14
+ em: EntityManager
15
+ tenantId: string | null
16
+ organizationId: string | null
17
+ }
18
+
19
+ export async function enrichCustomerDetailResponse<T extends Record<string, unknown>>(
20
+ payload: T,
21
+ recordKey: DetailRecordKey,
22
+ context: DetailEnrichmentContext,
23
+ ): Promise<T> {
24
+ if (getEnrichersForEntity(`customers.${recordKey}`).length === 0) return payload
25
+ const clonedPayload = structuredClone(payload)
26
+ const mainRecord = clonedPayload[recordKey]
27
+ if (!context.tenantId || !context.organizationId || !mainRecord || typeof mainRecord !== 'object' || Array.isArray(mainRecord)) {
28
+ return clonedPayload
29
+ }
30
+ let userFeatures: string[] | undefined
31
+ try {
32
+ const rbac = context.container.resolve<RbacService>('rbacService')
33
+ if (rbac?.getGrantedFeatures) {
34
+ userFeatures = await rbac.getGrantedFeatures(context.auth.sub, {
35
+ tenantId: context.tenantId,
36
+ organizationId: context.organizationId,
37
+ })
38
+ }
39
+ } catch {
40
+ userFeatures = undefined
41
+ }
42
+ const record = mainRecord as Record<string, unknown>
43
+ const result = await applyResponseEnricherToRecord(structuredClone(record), `customers.${recordKey}`, {
44
+ tenantId: context.tenantId,
45
+ organizationId: context.organizationId,
46
+ userId: context.auth.sub,
47
+ em: context.em,
48
+ container: context.container,
49
+ userFeatures,
50
+ })
51
+ const namespaces = extractResponseEnricherNamespaces(result.record)
52
+ return { ...clonedPayload, ...namespaces, [recordKey]: { ...record, ...namespaces } }
53
+ }
@@ -22,7 +22,7 @@ function SyncStatusBadge({ status, lastSynced }: { status: ExternalIdMapping['sy
22
22
  const label = t(config.label, status)
23
23
 
24
24
  return (
25
- <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
25
+ <span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
26
26
  <span className={cn('inline-block size-1.5 rounded-full', config.dot)} aria-hidden="true" />
27
27
  <span>{label}</span>
28
28
  {lastSynced && (
@@ -60,11 +60,11 @@ export default function ExternalIdsWidget({ data }: InjectionWidgetComponentProp
60
60
  key={integrationId}
61
61
  className="flex items-center justify-between gap-2 rounded-md bg-muted/50 px-3 py-2"
62
62
  >
63
- <div className="flex items-center gap-2 min-w-0">
63
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
64
64
  <span className="text-sm font-medium truncate">
65
65
  {getIntegrationTitle(integrationId)}
66
66
  </span>
67
- <code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono text-muted-foreground">
67
+ <code className="min-w-0 max-w-full break-all rounded bg-muted px-1.5 py-0.5 text-xs font-mono text-muted-foreground">
68
68
  {mapping.externalId}
69
69
  </code>
70
70
  {mapping.externalUrl && (
@@ -72,7 +72,7 @@ export default function ExternalIdsWidget({ data }: InjectionWidgetComponentProp
72
72
  href={mapping.externalUrl}
73
73
  target="_blank"
74
74
  rel="noopener noreferrer"
75
- className="text-muted-foreground hover:text-foreground transition-colors"
75
+ className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
76
76
  aria-label={t('integrations.externalIds.openExternal', 'Open in external system')}
77
77
  >
78
78
  <ExternalLinkIcon />