@open-mercato/core 0.10.1-develop.7761.1.9dfaf470ed → 0.10.1-develop.7763.1.caea616ebf
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/auth/api/profile/route.js +16 -2
- package/dist/modules/auth/api/profile/route.js.map +2 -2
- package/dist/modules/auth/lib/userDataSubject.js +26 -0
- package/dist/modules/auth/lib/userDataSubject.js.map +7 -0
- package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js +1 -0
- package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js.map +2 -2
- package/dist/modules/customers/api/companies/[id]/route.js +3 -2
- package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/deals/[id]/route.js +11 -12
- package/dist/modules/customers/api/deals/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/route.js +3 -2
- package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies/create/page.js +6 -0
- package/dist/modules/customers/backend/customers/companies/create/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +25 -25
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealInjectedTabs.js +8 -27
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealInjectedTabs.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js +175 -171
- package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +25 -24
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +2 -2
- package/dist/modules/customers/backend/customers/people/create/page.js +9 -0
- package/dist/modules/customers/backend/customers/people/create/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +140 -144
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/components/detail/CompanyDetailHeader.js +2 -0
- package/dist/modules/customers/components/detail/CompanyDetailHeader.js.map +2 -2
- package/dist/modules/customers/components/detail/CompanyDetailTabs.js +13 -4
- package/dist/modules/customers/components/detail/CompanyDetailTabs.js.map +2 -2
- package/dist/modules/customers/components/detail/CreatePersonDialog.js +13 -3
- package/dist/modules/customers/components/detail/CreatePersonDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/CustomerDetailSidebar.js +29 -0
- package/dist/modules/customers/components/detail/CustomerDetailSidebar.js.map +7 -0
- package/dist/modules/customers/components/detail/DealDetailHeader.js +4 -2
- package/dist/modules/customers/components/detail/DealDetailHeader.js.map +2 -2
- package/dist/modules/customers/components/detail/DealDetailTabs.js +9 -1
- package/dist/modules/customers/components/detail/DealDetailTabs.js.map +2 -2
- package/dist/modules/customers/components/detail/DealForm.js +5 -0
- package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
- package/dist/modules/customers/components/detail/PersonDetailHeader.js +2 -0
- package/dist/modules/customers/components/detail/PersonDetailHeader.js.map +2 -2
- package/dist/modules/customers/components/detail/PersonDetailTabs.js +9 -1
- package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
- package/dist/modules/customers/components/detail/create/CreateDealForm.js +160 -176
- package/dist/modules/customers/components/detail/create/CreateDealForm.js.map +2 -2
- package/dist/modules/customers/components/detail/useCustomerInjectedTabs.js +47 -0
- package/dist/modules/customers/components/detail/useCustomerInjectedTabs.js.map +7 -0
- package/dist/modules/customers/components/formConfig.js +3 -0
- package/dist/modules/customers/components/formConfig.js.map +2 -2
- package/dist/modules/customers/extension-points.js +14 -4
- package/dist/modules/customers/extension-points.js.map +2 -2
- package/dist/modules/customers/lib/detailResponseEnrichment.js +38 -0
- package/dist/modules/customers/lib/detailResponseEnrichment.js.map +7 -0
- package/dist/modules/dashboards/api/layout/[itemId]/route.js +11 -4
- package/dist/modules/dashboards/api/layout/[itemId]/route.js.map +2 -2
- package/dist/modules/dashboards/api/layout/route.js +25 -13
- package/dist/modules/dashboards/api/layout/route.js.map +2 -2
- package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js +4 -4
- package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/api/profile/route.ts +15 -1
- package/src/modules/auth/lib/userDataSubject.ts +30 -0
- package/src/modules/customer_accounts/widgets/injection/account-status/widget.client.tsx +2 -0
- package/src/modules/customers/api/companies/[id]/route.ts +3 -2
- package/src/modules/customers/api/deals/[id]/route.ts +12 -12
- package/src/modules/customers/api/people/[id]/route.ts +3 -2
- package/src/modules/customers/backend/customers/companies/create/page.tsx +6 -0
- package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +27 -30
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealInjectedTabs.tsx +9 -44
- package/src/modules/customers/backend/customers/deals/[id]/page.tsx +7 -3
- package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +25 -27
- package/src/modules/customers/backend/customers/people/create/page.tsx +9 -0
- package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +21 -33
- package/src/modules/customers/components/detail/CompanyDetailHeader.tsx +3 -0
- package/src/modules/customers/components/detail/CompanyDetailTabs.tsx +15 -4
- package/src/modules/customers/components/detail/CreatePersonDialog.tsx +15 -4
- package/src/modules/customers/components/detail/CustomerDetailSidebar.tsx +41 -0
- package/src/modules/customers/components/detail/DealDetailHeader.tsx +4 -1
- package/src/modules/customers/components/detail/DealDetailTabs.tsx +11 -1
- package/src/modules/customers/components/detail/DealForm.tsx +5 -0
- package/src/modules/customers/components/detail/PersonDetailHeader.tsx +3 -0
- package/src/modules/customers/components/detail/PersonDetailTabs.tsx +11 -1
- package/src/modules/customers/components/detail/create/CreateDealForm.tsx +126 -209
- package/src/modules/customers/components/detail/useCustomerInjectedTabs.tsx +64 -0
- package/src/modules/customers/components/formConfig.tsx +3 -0
- package/src/modules/customers/extension-points.ts +14 -4
- package/src/modules/customers/lib/detailResponseEnrichment.ts +53 -0
- package/src/modules/dashboards/api/layout/[itemId]/route.ts +12 -4
- package/src/modules/dashboards/api/layout/route.ts +25 -12
- 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
|
|
54
|
-
//
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}, [
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
124
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
</
|
|
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
|
+
}
|
|
@@ -2,6 +2,8 @@ import { NextResponse } from 'next/server'
|
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
4
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
6
|
+
import { resolveUserDataSubject } from '@open-mercato/core/modules/auth/lib/userDataSubject'
|
|
5
7
|
import { DashboardLayout } from '@open-mercato/core/modules/dashboards/data/entities'
|
|
6
8
|
import { dashboardLayoutItemPatchSchema } from '@open-mercato/core/modules/dashboards/data/validators'
|
|
7
9
|
import {
|
|
@@ -48,14 +50,20 @@ export async function PATCH(req: Request, ctx: { params?: { itemId?: string } })
|
|
|
48
50
|
const em = resolve('em') as any
|
|
49
51
|
const rbac = resolve('rbacService') as any
|
|
50
52
|
|
|
53
|
+
const userId = await resolveUserDataSubject(em, auth)
|
|
54
|
+
if (!userId) {
|
|
55
|
+
const { translate } = await resolveTranslations()
|
|
56
|
+
return NextResponse.json({ error: translate('api.errors.forbidden', 'Forbidden') }, { status: 403 })
|
|
57
|
+
}
|
|
58
|
+
|
|
51
59
|
const scope = {
|
|
52
|
-
userId
|
|
60
|
+
userId,
|
|
53
61
|
tenantId: auth.tenantId ?? null,
|
|
54
62
|
organizationId: auth.orgId ?? null,
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
const canConfigure = await rbac.userHasAllFeatures(
|
|
58
|
-
|
|
66
|
+
auth.sub,
|
|
59
67
|
['dashboards.configure'],
|
|
60
68
|
{ tenantId: scope.tenantId, organizationId: scope.organizationId },
|
|
61
69
|
)
|
|
@@ -66,7 +74,7 @@ export async function PATCH(req: Request, ctx: { params?: { itemId?: string } })
|
|
|
66
74
|
const guardResult = await validateCrudMutationGuard(container, {
|
|
67
75
|
tenantId: scope.tenantId ?? '',
|
|
68
76
|
organizationId: scope.organizationId,
|
|
69
|
-
userId:
|
|
77
|
+
userId: auth.sub,
|
|
70
78
|
resourceKind: RESOURCE_KIND,
|
|
71
79
|
resourceId: layoutItemId,
|
|
72
80
|
operation: 'update',
|
|
@@ -105,7 +113,7 @@ export async function PATCH(req: Request, ctx: { params?: { itemId?: string } })
|
|
|
105
113
|
await runCrudMutationGuardAfterSuccess(container, {
|
|
106
114
|
tenantId: scope.tenantId ?? '',
|
|
107
115
|
organizationId: scope.organizationId,
|
|
108
|
-
userId:
|
|
116
|
+
userId: auth.sub,
|
|
109
117
|
resourceKind: RESOURCE_KIND,
|
|
110
118
|
resourceId: layoutItemId,
|
|
111
119
|
operation: 'update',
|