@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.
Files changed (92) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/profile/route.js +16 -2
  3. package/dist/modules/auth/api/profile/route.js.map +2 -2
  4. package/dist/modules/auth/lib/userDataSubject.js +26 -0
  5. package/dist/modules/auth/lib/userDataSubject.js.map +7 -0
  6. package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js +1 -0
  7. package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js.map +2 -2
  8. package/dist/modules/customers/api/companies/[id]/route.js +3 -2
  9. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  10. package/dist/modules/customers/api/deals/[id]/route.js +11 -12
  11. package/dist/modules/customers/api/deals/[id]/route.js.map +2 -2
  12. package/dist/modules/customers/api/people/[id]/route.js +3 -2
  13. package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
  14. package/dist/modules/customers/backend/customers/companies/create/page.js +6 -0
  15. package/dist/modules/customers/backend/customers/companies/create/page.js.map +2 -2
  16. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +25 -25
  17. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  18. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealInjectedTabs.js +8 -27
  19. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealInjectedTabs.js.map +2 -2
  20. package/dist/modules/customers/backend/customers/deals/[id]/page.js +175 -171
  21. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  22. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +25 -24
  23. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +2 -2
  24. package/dist/modules/customers/backend/customers/people/create/page.js +9 -0
  25. package/dist/modules/customers/backend/customers/people/create/page.js.map +2 -2
  26. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +140 -144
  27. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  28. package/dist/modules/customers/components/detail/CompanyDetailHeader.js +2 -0
  29. package/dist/modules/customers/components/detail/CompanyDetailHeader.js.map +2 -2
  30. package/dist/modules/customers/components/detail/CompanyDetailTabs.js +13 -4
  31. package/dist/modules/customers/components/detail/CompanyDetailTabs.js.map +2 -2
  32. package/dist/modules/customers/components/detail/CreatePersonDialog.js +13 -3
  33. package/dist/modules/customers/components/detail/CreatePersonDialog.js.map +2 -2
  34. package/dist/modules/customers/components/detail/CustomerDetailSidebar.js +29 -0
  35. package/dist/modules/customers/components/detail/CustomerDetailSidebar.js.map +7 -0
  36. package/dist/modules/customers/components/detail/DealDetailHeader.js +4 -2
  37. package/dist/modules/customers/components/detail/DealDetailHeader.js.map +2 -2
  38. package/dist/modules/customers/components/detail/DealDetailTabs.js +9 -1
  39. package/dist/modules/customers/components/detail/DealDetailTabs.js.map +2 -2
  40. package/dist/modules/customers/components/detail/DealForm.js +5 -0
  41. package/dist/modules/customers/components/detail/DealForm.js.map +2 -2
  42. package/dist/modules/customers/components/detail/PersonDetailHeader.js +2 -0
  43. package/dist/modules/customers/components/detail/PersonDetailHeader.js.map +2 -2
  44. package/dist/modules/customers/components/detail/PersonDetailTabs.js +9 -1
  45. package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
  46. package/dist/modules/customers/components/detail/create/CreateDealForm.js +160 -176
  47. package/dist/modules/customers/components/detail/create/CreateDealForm.js.map +2 -2
  48. package/dist/modules/customers/components/detail/useCustomerInjectedTabs.js +47 -0
  49. package/dist/modules/customers/components/detail/useCustomerInjectedTabs.js.map +7 -0
  50. package/dist/modules/customers/components/formConfig.js +3 -0
  51. package/dist/modules/customers/components/formConfig.js.map +2 -2
  52. package/dist/modules/customers/extension-points.js +14 -4
  53. package/dist/modules/customers/extension-points.js.map +2 -2
  54. package/dist/modules/customers/lib/detailResponseEnrichment.js +38 -0
  55. package/dist/modules/customers/lib/detailResponseEnrichment.js.map +7 -0
  56. package/dist/modules/dashboards/api/layout/[itemId]/route.js +11 -4
  57. package/dist/modules/dashboards/api/layout/[itemId]/route.js.map +2 -2
  58. package/dist/modules/dashboards/api/layout/route.js +25 -13
  59. package/dist/modules/dashboards/api/layout/route.js.map +2 -2
  60. package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js +4 -4
  61. package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js.map +2 -2
  62. package/package.json +7 -7
  63. package/src/modules/auth/api/profile/route.ts +15 -1
  64. package/src/modules/auth/lib/userDataSubject.ts +30 -0
  65. package/src/modules/customer_accounts/widgets/injection/account-status/widget.client.tsx +2 -0
  66. package/src/modules/customers/api/companies/[id]/route.ts +3 -2
  67. package/src/modules/customers/api/deals/[id]/route.ts +12 -12
  68. package/src/modules/customers/api/people/[id]/route.ts +3 -2
  69. package/src/modules/customers/backend/customers/companies/create/page.tsx +6 -0
  70. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +27 -30
  71. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealInjectedTabs.tsx +9 -44
  72. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +7 -3
  73. package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +25 -27
  74. package/src/modules/customers/backend/customers/people/create/page.tsx +9 -0
  75. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +21 -33
  76. package/src/modules/customers/components/detail/CompanyDetailHeader.tsx +3 -0
  77. package/src/modules/customers/components/detail/CompanyDetailTabs.tsx +15 -4
  78. package/src/modules/customers/components/detail/CreatePersonDialog.tsx +15 -4
  79. package/src/modules/customers/components/detail/CustomerDetailSidebar.tsx +41 -0
  80. package/src/modules/customers/components/detail/DealDetailHeader.tsx +4 -1
  81. package/src/modules/customers/components/detail/DealDetailTabs.tsx +11 -1
  82. package/src/modules/customers/components/detail/DealForm.tsx +5 -0
  83. package/src/modules/customers/components/detail/PersonDetailHeader.tsx +3 -0
  84. package/src/modules/customers/components/detail/PersonDetailTabs.tsx +11 -1
  85. package/src/modules/customers/components/detail/create/CreateDealForm.tsx +126 -209
  86. package/src/modules/customers/components/detail/useCustomerInjectedTabs.tsx +64 -0
  87. package/src/modules/customers/components/formConfig.tsx +3 -0
  88. package/src/modules/customers/extension-points.ts +14 -4
  89. package/src/modules/customers/lib/detailResponseEnrichment.ts +53 -0
  90. package/src/modules/dashboards/api/layout/[itemId]/route.ts +12 -4
  91. package/src/modules/dashboards/api/layout/route.ts +25 -12
  92. package/src/modules/integrations/widgets/injection/external-ids/widget.client.tsx +4 -4
@@ -14,6 +14,7 @@ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
14
14
  import { buildPasswordSchema } from '@open-mercato/shared/lib/auth/passwordPolicy'
15
15
  import { createLogger } from '@open-mercato/shared/lib/logger'
16
16
  import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
17
+ import { resolveUserDataSubject } from '@open-mercato/core/modules/auth/lib/userDataSubject'
17
18
 
18
19
  const logger = createLogger('auth').child({ component: 'profile' })
19
20
 
@@ -98,10 +99,18 @@ export async function GET(req: Request) {
98
99
  try {
99
100
  const container = await createRequestContainer()
100
101
  const em = (container.resolve('em') as EntityManager)
102
+ const userId = await resolveUserDataSubject(em, auth)
103
+ if (!userId) {
104
+ return NextResponse.json({ error: translate('api.errors.forbidden', 'Forbidden') }, { status: 403 })
105
+ }
101
106
  const user = await findOneWithDecryption(
102
107
  em,
103
108
  User,
104
- { id: auth.sub, deletedAt: null },
109
+ {
110
+ id: userId,
111
+ deletedAt: null,
112
+ ...(auth.isApiKey ? { tenantId: auth.tenantId, ...(auth.orgId ? { organizationId: auth.orgId } : {}) } : {}),
113
+ },
105
114
  undefined,
106
115
  { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null },
107
116
  )
@@ -126,6 +135,9 @@ export async function PUT(req: Request) {
126
135
  if (!auth?.sub) {
127
136
  return NextResponse.json({ error: translate('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })
128
137
  }
138
+ if (auth.isApiKey) {
139
+ return NextResponse.json({ error: translate('api.errors.forbidden', 'Forbidden') }, { status: 403 })
140
+ }
129
141
  try {
130
142
  const body = await req.json().catch(() => ({}))
131
143
  const parsed = buildUpdateSchema(translate).safeParse(body)
@@ -222,6 +234,7 @@ export const openApi: OpenApiRouteDoc = {
222
234
  responses: [
223
235
  { status: 200, description: 'Profile payload', schema: profileResponseSchema },
224
236
  { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },
237
+ { status: 403, description: 'No user binding in the authenticated scope', schema: z.object({ error: z.string() }) },
225
238
  { status: 404, description: 'User not found', schema: z.object({ error: z.string() }) },
226
239
  ],
227
240
  },
@@ -236,6 +249,7 @@ export const openApi: OpenApiRouteDoc = {
236
249
  { status: 200, description: 'Profile updated', schema: profileUpdateResponseSchema },
237
250
  { status: 400, description: 'Invalid payload', schema: z.object({ error: z.string() }) },
238
251
  { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },
252
+ { status: 403, description: 'Interactive session required', schema: z.object({ error: z.string() }) },
239
253
  ],
240
254
  },
241
255
  },
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
4
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { User } from '../data/entities'
6
+
7
+ const uuidSchema = z.string().uuid()
8
+
9
+ export async function resolveUserDataSubject(
10
+ em: EntityManager,
11
+ auth: NonNullable<AuthContext>,
12
+ ): Promise<string | null> {
13
+ if (!auth.isApiKey) return auth.sub
14
+ if (!uuidSchema.safeParse(auth.userId).success || !uuidSchema.safeParse(auth.tenantId).success) return null
15
+ if (auth.orgId !== null && !uuidSchema.safeParse(auth.orgId).success) return null
16
+
17
+ const user = await findOneWithDecryption(
18
+ em,
19
+ User,
20
+ {
21
+ id: auth.userId,
22
+ tenantId: auth.tenantId,
23
+ ...(auth.orgId ? { organizationId: auth.orgId } : {}),
24
+ deletedAt: null,
25
+ },
26
+ undefined,
27
+ { tenantId: auth.tenantId, organizationId: auth.orgId },
28
+ )
29
+ return user?.id ?? null
30
+ }
@@ -325,6 +325,8 @@ export default function AccountStatusWidget({ context }: AccountStatusProps) {
325
325
  queryClient.invalidateQueries({ queryKey: ['customer-account-pending-invitation', personEntityId] })
326
326
  }
327
327
 
328
+ if (!personEntityId) return null
329
+
328
330
  if (isLoading) {
329
331
  return <div className="text-sm text-muted-foreground">{t('common.loading', 'Loading...')}</div>
330
332
  }
@@ -1,3 +1,4 @@
1
+ import { enrichCustomerDetailResponse } from '../../../lib/detailResponseEnrichment'
1
2
  import { NextResponse } from 'next/server'
2
3
  import { z } from 'zod'
3
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
@@ -524,7 +525,7 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
524
525
  : null
525
526
  if (cache && cacheKey) {
526
527
  const cached = await runWithCacheTenant(cacheTenantId, () => cache.get(cacheKey))
527
- if (cached) return NextResponse.json(cached)
528
+ if (cached) return NextResponse.json(await enrichCustomerDetailResponse(cached as Record<string, unknown>, 'company', { auth, container, em, tenantId: companyScope.tenantId, organizationId: companyScope.organizationId }))
528
529
  }
529
530
 
530
531
  const shouldLoadCanonicalInteractions = includeInteractions || includeActivities || includeTodos
@@ -1211,7 +1212,7 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
1211
1212
  }
1212
1213
  }
1213
1214
 
1214
- return NextResponse.json(payload)
1215
+ return NextResponse.json(await enrichCustomerDetailResponse(payload, 'company', { auth, container, em, tenantId: companyScope.tenantId, organizationId: companyScope.organizationId }))
1215
1216
  }
1216
1217
 
1217
1218
  const companyDetailQuerySchema = z.object({
@@ -1,3 +1,4 @@
1
+ import { enrichCustomerDetailResponse } from '../../../lib/detailResponseEnrichment'
1
2
  import { NextResponse } from 'next/server'
2
3
  import { z } from 'zod'
3
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
@@ -491,20 +492,11 @@ export async function GET(request: Request, context: { params?: Record<string, u
491
492
  })
492
493
  : null
493
494
 
495
+ let cachedBody: Record<string, unknown> | null = null
494
496
  if (detailCache && detailCacheKey) {
495
497
  try {
496
498
  const cached = await runWithCacheTenant(cacheTenantId, () => detailCache.get(detailCacheKey))
497
- if (cached && typeof cached === 'object') {
498
- const viewerInfo = await resolveViewer()
499
- return NextResponse.json({
500
- ...(cached as Record<string, unknown>),
501
- viewer: {
502
- userId: viewerUserId,
503
- name: viewerInfo.name,
504
- email: viewerInfo.email,
505
- },
506
- })
507
- }
499
+ if (cached && typeof cached === 'object') cachedBody = cached as Record<string, unknown>
508
500
  } catch (err) {
509
501
  // A cache-backend read error must degrade to a fresh DB read, never a 500.
510
502
  debugCrudCache('get', {
@@ -514,6 +506,14 @@ export async function GET(request: Request, context: { params?: Record<string, u
514
506
  })
515
507
  }
516
508
  }
509
+ if (cachedBody) {
510
+ const viewerInfo = await resolveViewer()
511
+ return NextResponse.json(await enrichCustomerDetailResponse({
512
+ ...cachedBody,
513
+ viewer: { userId: viewerUserId, name: viewerInfo.name, email: viewerInfo.email },
514
+ }, 'deal', { auth, container, em, tenantId: cacheTenantId, organizationId: cacheOrganizationId }))
515
+ }
516
+
517
517
  // Issue #3175: the reads below only depend on the already-resolved deal +
518
518
  // organization scope + decryption scope, so they are grouped into Promise.all
519
519
  // batches instead of being awaited one after another. True dependencies stay
@@ -872,7 +872,7 @@ export async function GET(request: Request, context: { params?: Record<string, u
872
872
  }
873
873
  }
874
874
 
875
- return NextResponse.json({ ...cacheableBody, viewer })
875
+ return NextResponse.json(await enrichCustomerDetailResponse({ ...cacheableBody, viewer }, 'deal', { auth, container, em, tenantId: cacheTenantId, organizationId: cacheOrganizationId }))
876
876
  }
877
877
 
878
878
  const dealDetailQuerySchema = z.object({
@@ -1,3 +1,4 @@
1
+ import { enrichCustomerDetailResponse } from '../../../lib/detailResponseEnrichment'
1
2
  import { NextResponse } from 'next/server'
2
3
  import { z } from 'zod'
3
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
@@ -579,7 +580,7 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
579
580
  statusCode = 200
580
581
  profileMeta = { cacheHit: true, include: Array.from(includeTokens), interactionMode }
581
582
  profiler.mark('cache_hit')
582
- return NextResponse.json(cached)
583
+ return NextResponse.json(await enrichCustomerDetailResponse(cached as Record<string, unknown>, 'person', { auth, container, em, tenantId: personDetailTenantId, organizationId: personDetailOrganizationId }))
583
584
  }
584
585
  }
585
586
 
@@ -1134,7 +1135,7 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
1134
1135
  } catch {}
1135
1136
  }
1136
1137
 
1137
- const response = NextResponse.json(payload)
1138
+ const response = NextResponse.json(await enrichCustomerDetailResponse(payload, 'person', { auth, container, em, tenantId: personDetailTenantId, organizationId: personDetailOrganizationId }))
1138
1139
  statusCode = 200
1139
1140
  profileMeta = {
1140
1141
  include: Array.from(includeTokens),
@@ -45,6 +45,8 @@ export default function CreateCompanyPage() {
45
45
  initialValues={{ addresses: [] as CompanyFormValues['addresses'] }}
46
46
  injectionSpotId={extensionPoints.hosts.companyForm.spotId}
47
47
  legacyInjectionSpotId={COMPANY_CREATE_LEGACY_INJECTION_SPOT_ID}
48
+ entityId="customers.company"
49
+ resourceKind="customers.company"
48
50
  entityIds={[E.customers.customer_entity, E.customers.customer_company_profile]}
49
51
  submitLabel={t('customers.companies.form.submit')}
50
52
  cancelHref={returnTo ?? '/backend/customers/companies'}
@@ -125,6 +127,10 @@ export default function CreateCompanyPage() {
125
127
  }
126
128
 
127
129
  flash(t('customers.companies.form.success'), 'success')
130
+ return newId ? { resourceId: newId } : undefined
131
+ }}
132
+ onSubmitSuccess={async (_values, result) => {
133
+ const newId = result?.resourceId
128
134
  if (returnTo) router.push(returnTo)
129
135
  else if (newId) router.push(`/backend/customers/companies-v2/${newId}`)
130
136
  else router.push('/backend/customers/companies')
@@ -15,7 +15,9 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
15
15
  import { Button } from '@open-mercato/ui/primitives/button'
16
16
  import { AttachmentsSection, ErrorMessage, LoadingMessage, RecordNotFoundState, type SectionAction } from '@open-mercato/ui/backend/detail'
17
17
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
18
- import { InjectionSpot, useInjectionWidgets } from '@open-mercato/ui/backend/injection/InjectionSpot'
18
+ import { InjectionSpot } from '@open-mercato/ui/backend/injection/InjectionSpot'
19
+ import { useCustomerInjectedTabs } from '../../../../components/detail/useCustomerInjectedTabs'
20
+ import { CustomerDetailSidebar } from '../../../../components/detail/CustomerDetailSidebar'
19
21
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
20
22
  import { buildRecordInjectionContext, useSetCurrentRecordInjectionContext } from '@open-mercato/ui/backend/injection/recordContext'
21
23
  import { createTranslatorWithFallback } from '@open-mercato/shared/lib/i18n/translate'
@@ -32,7 +34,7 @@ import type { TagsSectionController } from '@open-mercato/ui/backend/detail'
32
34
  import { coerceDisplayName } from '../../../../lib/displayName'
33
35
  import { isDetailNotFoundStatus } from '@open-mercato/core/modules/customers/lib/detailHelpers'
34
36
  import { CompanyDetailHeader } from '../../../../components/detail/CompanyDetailHeader'
35
- import { CompanyDetailTabs, resolveLegacyTab, type CompanyTabId } from '../../../../components/detail/CompanyDetailTabs'
37
+ import { CompanyDetailTabs, COMPANY_DETAIL_TAB_IDS, resolveLegacyTab, type CompanyTabId } from '../../../../components/detail/CompanyDetailTabs'
36
38
  import { useDealsAccess } from '../../../../components/detail/useDealsAccess'
37
39
  import { CompanyKpiBar } from '../../../../components/detail/CompanyKpiBar'
38
40
  import { ScheduleActivityDialog, type ScheduleActivityEditData } from '../../../../components/detail/ScheduleActivityDialog'
@@ -192,9 +194,11 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
192
194
  resourceKind: 'customers.company',
193
195
  resourceId: currentCompanyId ?? (id ?? undefined),
194
196
  data,
197
+ isDirty,
198
+ isSaving,
195
199
  retryLastMutation,
196
200
  }),
197
- [currentCompanyId, data, id, mutationContextId, retryLastMutation],
201
+ [currentCompanyId, data, isDirty, isSaving, id, mutationContextId, retryLastMutation],
198
202
  )
199
203
  const runMutationWithContext = React.useCallback(
200
204
  async <T,>(operation: () => Promise<T>, mutationPayload?: Record<string, unknown>): Promise<T> => {
@@ -295,36 +299,19 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
295
299
  setScheduleDialogOpen(true)
296
300
  }, [])
297
301
 
298
- // Injected tabs from UMES
299
- const { widgets: injectedTabWidgets } = useInjectionWidgets('detail:customers.company:tabs', {
302
+ const { injectedTabs, injectedTabMap, loading: injectedTabsLoading } = useCustomerInjectedTabs({
303
+ spotId: extensionPoints.hosts.companyTabs.spotId,
300
304
  context: injectionContext,
301
- triggerOnLoad: true,
305
+ data,
306
+ onDataChange: setData,
307
+ nativeTabIds: COMPANY_DETAIL_TAB_IDS,
302
308
  })
303
309
 
304
- const injectedTabs = React.useMemo(
305
- () =>
306
- (injectedTabWidgets ?? [])
307
- .filter((widget) => (widget.placement?.kind ?? 'tab') === 'tab')
308
- .map((widget) => {
309
- const tabId = widget.placement?.groupId ?? widget.widgetId
310
- const label = widget.placement?.groupLabel
311
- ? t(widget.placement.groupLabel, widget.placement.groupLabel)
312
- : widget.module.metadata.title ?? tabId
313
- const priority = typeof widget.placement?.priority === 'number' ? widget.placement.priority : 0
314
- const render = () => (
315
- <widget.module.Widget
316
- context={injectionContext}
317
- data={data}
318
- onDataChange={(next: unknown) => setData(next as CompanyOverview)}
319
- />
320
- )
321
- return { id: tabId, label, priority, render }
322
- })
323
- .sort((a, b) => b.priority - a.priority),
324
- [data, injectedTabWidgets, injectionContext, t],
325
- )
326
-
327
- const injectedTabMap = React.useMemo(() => new Map(injectedTabs.map((tab) => [tab.id, tab.render])), [injectedTabs])
310
+ const injectedTabIds = React.useMemo(() => injectedTabs.map((tab) => tab.id), [injectedTabs])
311
+ React.useEffect(() => {
312
+ const requested = resolveLegacyTab(searchParams?.get('tab'), injectedTabIds)
313
+ setActiveTab(requested === 'deals' && isDealsAccessReady && !canViewDeals ? 'people' : requested)
314
+ }, [searchParams, injectedTabIds, isDealsAccessReady, canViewDeals])
328
315
 
329
316
  // Tags
330
317
  const handleTagsChange = React.useCallback((nextTags: TagSummary[]) => {
@@ -492,6 +479,7 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
492
479
 
493
480
  {/* Persistent company header */}
494
481
  <CompanyDetailHeader
482
+ actions={<InjectionSpot spotId={extensionPoints.hosts.companyHeaderActions.spotId} context={injectionContext} data={data} disabled={isSaving} />}
495
483
  data={data}
496
484
  onTagsChange={handleTagsChange}
497
485
  tagsSectionControllerRef={tagsSectionControllerRef}
@@ -506,6 +494,7 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
506
494
  <CompanyKpiBar data={data} />
507
495
 
508
496
  {/* Two-zone layout: zone1 = form, zone2 = tabs */}
497
+ <CustomerDetailSidebar spotId={extensionPoints.hosts.companySidebar.spotId} context={injectionContext} data={data} onDataChange={setData} disabled={isSaving}>
509
498
  <CollapsibleZoneLayout
510
499
  pageType="company-v2"
511
500
  entityName={companyName}
@@ -517,6 +506,10 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
517
506
  embedded
518
507
  trackDirtyWhenEmbedded
519
508
  injectionSpotId={extensionPoints.hosts.companyForm.spotId}
509
+ entityId="customers.company"
510
+ resourceKind="customers.company"
511
+ resourceId={companyId}
512
+ injectionGroupAliases={{ details: 'identity', profile: 'businessProfile' }}
520
513
  entityIds={[E.customers.customer_entity, E.customers.customer_company_profile]}
521
514
  schema={formSchema}
522
515
  fields={formFields}
@@ -536,6 +529,7 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
536
529
  <CompanyDetailTabs
537
530
  activeTab={activeTab}
538
531
  onTabChange={handleTabChange}
532
+ isLoadingInjectedTabs={injectedTabsLoading}
539
533
  injectedTabs={injectedTabs.map((tab) => ({ id: tab.id, label: tab.label }))}
540
534
  peopleCount={data.counts?.people ?? 0}
541
535
  dealsCount={dealCount}
@@ -621,7 +615,10 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
621
615
  }
622
616
  />
623
617
 
618
+ </CustomerDetailSidebar>
619
+
624
620
  {/* UMES footer injection */}
621
+
625
622
  <InjectionSpot spotId={extensionPoints.hosts.companyFooter.spotId} context={injectionContext} data={data} />
626
623
 
627
624
  {/* Schedule Activity Dialog */}
@@ -1,60 +1,25 @@
1
1
  import * as React from 'react'
2
- import { useInjectionWidgets } from '@open-mercato/ui/backend/injection/InjectionSpot'
2
+ import { useCustomerInjectedTabs } from '../../../../../components/detail/useCustomerInjectedTabs'
3
+ import { DEAL_DETAIL_TAB_IDS } from '../../../../../components/detail/DealDetailTabs'
4
+ import { extensionPoints } from '../../../../../extension-points'
3
5
  import type { DealDetailPayload } from './types'
4
6
 
5
- type InjectedTab = {
6
- id: string
7
- label: string
8
- priority: number
9
- render: () => React.ReactNode
10
- }
11
-
12
7
  type UseDealInjectedTabsOptions = {
13
8
  injectionContext: unknown
14
9
  data: DealDetailPayload | null
15
10
  setData: React.Dispatch<React.SetStateAction<DealDetailPayload | null>>
16
11
  }
17
12
 
18
- type UseDealInjectedTabsResult = {
19
- injectedTabs: InjectedTab[]
20
- injectedTabMap: Map<string, () => React.ReactNode>
21
- }
22
-
23
13
  export function useDealInjectedTabs({
24
14
  injectionContext,
25
15
  data,
26
16
  setData,
27
- }: UseDealInjectedTabsOptions): UseDealInjectedTabsResult {
28
- const { widgets: injectedTabWidgets } = useInjectionWidgets('detail:customers.deal:tabs', {
17
+ }: UseDealInjectedTabsOptions) {
18
+ return useCustomerInjectedTabs({
19
+ spotId: extensionPoints.hosts.dealTabs.spotId,
29
20
  context: injectionContext,
30
- triggerOnLoad: true,
21
+ data,
22
+ onDataChange: setData,
23
+ nativeTabIds: DEAL_DETAIL_TAB_IDS,
31
24
  })
32
-
33
- const injectedTabs = React.useMemo<InjectedTab[]>(
34
- () =>
35
- (injectedTabWidgets ?? [])
36
- .filter((widget) => (widget.placement?.kind ?? 'tab') === 'tab')
37
- .map((widget) => {
38
- const tabId = widget.placement?.groupId ?? widget.widgetId
39
- const label = widget.placement?.groupLabel ?? widget.module.metadata.title ?? tabId
40
- const priority = typeof widget.placement?.priority === 'number' ? widget.placement.priority : 0
41
- const render = () => (
42
- <widget.module.Widget
43
- context={injectionContext}
44
- data={data}
45
- onDataChange={(next: unknown) => setData(next as DealDetailPayload)}
46
- />
47
- )
48
- return { id: tabId, label, priority, render }
49
- })
50
- .sort((left, right) => right.priority - left.priority),
51
- [data, injectedTabWidgets, injectionContext, setData],
52
- )
53
-
54
- const injectedTabMap = React.useMemo(
55
- () => new Map(injectedTabs.map((tab) => [tab.id, tab.render])),
56
- [injectedTabs],
57
- )
58
-
59
- return { injectedTabs, injectedTabMap }
60
25
  }
@@ -10,6 +10,7 @@ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
10
10
  import { Button } from '@open-mercato/ui/primitives/button'
11
11
  import { AttachmentsSection, ErrorMessage, LoadingMessage, NotesSection, RecordNotFoundState } from '@open-mercato/ui/backend/detail'
12
12
  import { InjectionSpot } from '@open-mercato/ui/backend/injection/InjectionSpot'
13
+ import { CustomerDetailSidebar } from '../../../../components/detail/CustomerDetailSidebar'
13
14
  import { buildRecordInjectionContext, useSetCurrentRecordInjectionContext } from '@open-mercato/ui/backend/injection/recordContext'
14
15
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
15
16
  import { CollapsibleZoneLayout, type ZoneSectionDescriptor } from '@open-mercato/ui/backend/crud/CollapsibleZoneLayout'
@@ -100,7 +101,7 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
100
101
  [detailTranslator, runMutationWithContext],
101
102
  )
102
103
 
103
- const { injectedTabs, injectedTabMap } = useDealInjectedTabs({
104
+ const { injectedTabs, injectedTabMap, loading: injectedTabsLoading } = useDealInjectedTabs({
104
105
  injectionContext,
105
106
  data,
106
107
  setData,
@@ -428,8 +429,6 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
428
429
  const zone2Content = (
429
430
  <div className="rounded-[10px] border border-border bg-card px-5 py-5">
430
431
  {(() => {
431
- const injected = injectedTabMap.get(activeTab)
432
- if (injected) return injected()
433
432
 
434
433
  if (activeTab === 'activities') {
435
434
  const activityEntitySelection = activityEntities.length > 1 ? (
@@ -618,6 +617,7 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
618
617
 
619
618
  return null
620
619
  })()}
620
+ {injectedTabMap.get(activeTab)?.()}
621
621
  </div>
622
622
  )
623
623
 
@@ -628,6 +628,7 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
628
628
  <InjectionSpot spotId={extensionPoints.hosts.dealHeader.spotId} context={injectionContext} data={data} />
629
629
 
630
630
  <DealDetailHeader
631
+ actions={<InjectionSpot spotId={extensionPoints.hosts.dealHeaderActions.spotId} context={{ ...injectionContext, isDirty, isSaving }} data={data} disabled={isSaving} />}
631
632
  deal={data.deal}
632
633
  owner={data.owner}
633
634
  people={data.people}
@@ -661,7 +662,9 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
661
662
  )}
662
663
  />
663
664
 
665
+ <CustomerDetailSidebar spotId={extensionPoints.hosts.dealSidebar.spotId} context={injectionContext} data={data} onDataChange={setData} disabled={isSaving}>
664
666
  <DealDetailTabs
667
+ isLoadingInjectedTabs={injectedTabsLoading}
665
668
  activeTab={activeTab}
666
669
  onTabChange={handleTabChange}
667
670
  injectedTabs={injectedTabs.map((tab) => ({ id: tab.id, label: tab.label }))}
@@ -678,6 +681,7 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
678
681
  zone2={zone2Content}
679
682
  />
680
683
  </DealDetailTabs>
684
+ </CustomerDetailSidebar>
681
685
 
682
686
  <InjectionSpot spotId={extensionPoints.hosts.dealFooter.spotId} context={injectionContext} data={data} />
683
687
  </div>
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { z } from 'zod'
5
+ import { E } from '#generated/entities.ids.generated'
5
6
  import {
6
7
  Dialog,
7
8
  DialogContent,
@@ -14,6 +15,7 @@ import { CrudForm, type CrudField, type CrudFormGroup } from '@open-mercato/ui/b
14
15
  import { ComboboxInput } from '@open-mercato/ui/backend/inputs/ComboboxInput'
15
16
  import { createCrud } from '@open-mercato/ui/backend/utils/crud'
16
17
  import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
18
+ import { extensionPoints } from '../../../../../extension-points'
17
19
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
18
20
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
19
21
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -124,14 +126,8 @@ export function QuickDealDialog({
124
126
  if (open) setFormInstanceKey((current) => current + 1)
125
127
  }, [open])
126
128
 
127
- const { runMutation, retryLastMutation } = useGuardedMutation<{
128
- formId: string
129
- resourceKind: string
130
- retryLastMutation: () => Promise<boolean>
131
- }>({
132
- contextId: QUICK_DEAL_CONTEXT_ID,
133
- blockedMessage: translateWithFallback(t, 'ui.forms.flash.saveBlocked', 'Save blocked by validation'),
134
- })
129
+
130
+ const { runMutation, retryLastMutation } = useGuardedMutation({ contextId: QUICK_DEAL_CONTEXT_ID, blockedMessage: translateWithFallback(t, 'ui.forms.flash.saveBlocked', 'Save blocked by validation') })
135
131
 
136
132
  const titleRequiredMessage = translateWithFallback(
137
133
  t,
@@ -355,33 +351,28 @@ export function QuickDealDialog({
355
351
  if (values.description && values.description.trim().length) {
356
352
  payload.description = values.description
357
353
  }
358
- const operation = () =>
359
- createCrud('customers/deals', payload, {
354
+ const { result } = await runMutation({
355
+ operation: () => createCrud<{ id?: string; entityId?: string }>('customers/deals', payload, {
360
356
  errorMessage: translateWithFallback(t, 'customers.deals.create.error', 'Failed to create deal.'),
361
- })
362
- await runMutation({
363
- operation,
364
- context: {
365
- formId: QUICK_DEAL_CONTEXT_ID,
366
- resourceKind: 'customers.deal',
367
- retryLastMutation,
368
- },
357
+ }),
358
+ context: { formId: QUICK_DEAL_CONTEXT_ID, resourceKind: 'customers.deal', retryLastMutation },
359
+ mutationPayload: payload,
369
360
  })
370
361
  flash(
371
- translateWithFallback(
372
- t,
373
- 'customers.deals.kanban.quickDeal.success',
374
- 'Deal added to {stage}.',
375
- { stage: context.pipelineStageLabel },
376
- ),
362
+ translateWithFallback(t, 'customers.deals.kanban.quickDeal.success', 'Deal added to {stage}.', { stage: context.pipelineStageLabel }),
377
363
  'success',
378
364
  )
379
- onCreated()
380
- onClose()
365
+ return { resourceId: result?.id ?? result?.entityId }
381
366
  },
382
- [context, currentUserId, onClose, onCreated, retryLastMutation, runMutation, t, titleRequiredMessage],
367
+ [context, currentUserId, t, titleRequiredMessage, runMutation, retryLastMutation],
383
368
  )
384
369
 
370
+ const handleSubmitSuccess = React.useCallback(() => {
371
+ if (!context) return
372
+ onCreated()
373
+ onClose()
374
+ }, [context, onCreated, onClose])
375
+
385
376
  if (!context) return null
386
377
 
387
378
  return (
@@ -409,6 +400,12 @@ export function QuickDealDialog({
409
400
  <CrudForm<QuickDealFormValues>
410
401
  key={`${context.pipelineStageId}:${formInstanceKey}`}
411
402
  embedded
403
+ formId={QUICK_DEAL_CONTEXT_ID}
404
+ entityId="customers.deal"
405
+ entityIds={[E.customers.customer_deal]}
406
+ resourceKind="customers.deal"
407
+ injectionSpotId={extensionPoints.hosts.dealForm.spotId}
408
+ injectionGroupAliases={{ details: 'basic' }}
412
409
  fields={fields}
413
410
  groups={groups}
414
411
  // The "More details" group starts collapsed; users can expand to set
@@ -419,6 +416,7 @@ export function QuickDealDialog({
419
416
  schema={formSchema}
420
417
  submitLabel={translateWithFallback(t, 'customers.deals.kanban.quickDeal.submit', 'Add deal')}
421
418
  onSubmit={handleSubmit}
419
+ onSubmitSuccess={handleSubmitSuccess}
422
420
  />
423
421
  </DialogContent>
424
422
  </Dialog>
@@ -10,6 +10,7 @@ import { E } from '#generated/entities.ids.generated'
10
10
  import { useT } from '@open-mercato/shared/lib/i18n/context'
11
11
  import { useOrganizationScopeDetail } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
12
12
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
13
+ import { extensionPoints } from '../../../../extension-points'
13
14
  import {
14
15
  buildPersonPayload,
15
16
  createPersonFormFields,
@@ -52,6 +53,10 @@ export default function CreatePersonPage() {
52
53
  fields={fields}
53
54
  groups={groups}
54
55
  initialValues={initialValues}
56
+ injectionSpotId={extensionPoints.hosts.personForm.spotId}
57
+ legacyInjectionSpotId="crud-form:customers.customer_entity"
58
+ entityId="customers.person"
59
+ resourceKind="customers.person"
55
60
  entityIds={[E.customers.customer_entity, E.customers.customer_person_profile]}
56
61
  submitLabel={t('customers.people.form.submit')}
57
62
  cancelHref={returnTo ?? '/backend/customers/people'}
@@ -127,6 +132,10 @@ export default function CreatePersonPage() {
127
132
  }
128
133
 
129
134
  flash(t('customers.people.form.success'), 'success')
135
+ return newId ? { resourceId: newId } : undefined
136
+ }}
137
+ onSubmitSuccess={async (_values, result) => {
138
+ const newId = result?.resourceId
130
139
  if (returnTo) router.push(returnTo)
131
140
  else if (newId) router.push(`/backend/customers/people-v2/${newId}`)
132
141
  else router.push('/backend/customers/people')