@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
@@ -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')
@@ -23,7 +23,9 @@ import { useOrganizationScopeDetail } from '@open-mercato/shared/lib/frontend/us
23
23
  import { Button } from '@open-mercato/ui/primitives/button'
24
24
  import { AttachmentsSection, ErrorMessage, LoadingMessage, RecordNotFoundState, type SectionAction } from '@open-mercato/ui/backend/detail'
25
25
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
26
- import { InjectionSpot, useInjectionWidgets } from '@open-mercato/ui/backend/injection/InjectionSpot'
26
+ import { InjectionSpot } from '@open-mercato/ui/backend/injection/InjectionSpot'
27
+ import { useCustomerInjectedTabs } from '../../../../components/detail/useCustomerInjectedTabs'
28
+ import { CustomerDetailSidebar } from '../../../../components/detail/CustomerDetailSidebar'
27
29
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
28
30
  import { buildRecordInjectionContext, useSetCurrentRecordInjectionContext } from '@open-mercato/ui/backend/injection/recordContext'
29
31
  import { createTranslatorWithFallback } from '@open-mercato/shared/lib/i18n/translate'
@@ -38,7 +40,7 @@ import type { TagSummary } from '../../../../components/detail/types'
38
40
  import { ScheduleActivityDialog, type ScheduleActivityEditData } from '../../../../components/detail/ScheduleActivityDialog'
39
41
  import { PersonDetailHeader } from '../../../../components/detail/PersonDetailHeader'
40
42
  import { ChangelogTab } from '../../../../components/detail/ChangelogTab'
41
- import { PersonDetailTabs, resolveLegacyTab, type PersonTabId } from '../../../../components/detail/PersonDetailTabs'
43
+ import { PersonDetailTabs, PERSON_DETAIL_TAB_IDS, resolveLegacyTab, type PersonTabId } from '../../../../components/detail/PersonDetailTabs'
42
44
  import { AddressesSection } from '../../../../components/detail/AddressesSection'
43
45
  import { PersonCompaniesSection } from '../../../../components/detail/PersonCompaniesSection'
44
46
  import { MobilePersonDetail } from '../../../../components/detail/MobilePersonDetail'
@@ -207,9 +209,11 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
207
209
  resourceKind: 'customers.person',
208
210
  resourceId: currentPersonId ?? (id ?? undefined),
209
211
  data,
212
+ isDirty,
213
+ isSaving,
210
214
  retryLastMutation,
211
215
  }),
212
- [currentPersonId, data, id, mutationContextId, retryLastMutation],
216
+ [currentPersonId, data, isDirty, isSaving, id, mutationContextId, retryLastMutation],
213
217
  )
214
218
  const runMutationWithContext = React.useCallback(
215
219
  async <T,>(operation: () => Promise<T>, mutationPayload?: Record<string, unknown>): Promise<T> => {
@@ -298,37 +302,14 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
298
302
  setScheduleDialogOpen(true)
299
303
  }, [])
300
304
 
301
- // Injected tabs from UMES
302
- const { widgets: injectedTabWidgets } = useInjectionWidgets('detail:customers.person:tabs', {
305
+ const { injectedTabs, injectedTabMap, loading: injectedTabsLoading } = useCustomerInjectedTabs({
306
+ spotId: extensionPoints.hosts.personTabs.spotId,
303
307
  context: injectionContext,
304
- triggerOnLoad: true,
308
+ data,
309
+ onDataChange: setData,
310
+ nativeTabIds: PERSON_DETAIL_TAB_IDS,
305
311
  })
306
312
 
307
- const injectedTabs = React.useMemo(
308
- () =>
309
- (injectedTabWidgets ?? [])
310
- .filter((widget) => (widget.placement?.kind ?? 'tab') === 'tab')
311
- .map((widget) => {
312
- const tabId = widget.placement?.groupId ?? widget.widgetId
313
- const label = widget.placement?.groupLabel
314
- ? t(widget.placement.groupLabel, widget.placement.groupLabel)
315
- : widget.module.metadata.title ?? tabId
316
- const priority = typeof widget.placement?.priority === 'number' ? widget.placement.priority : 0
317
- const render = () => (
318
- <widget.module.Widget
319
- context={injectionContext}
320
- data={data}
321
- onDataChange={(next: unknown) => setData(next as PersonOverview)}
322
- />
323
- )
324
- return { id: tabId, label, priority, render }
325
- })
326
- .sort((a, b) => b.priority - a.priority),
327
- [data, injectedTabWidgets, injectionContext, t],
328
- )
329
-
330
- const injectedTabMap = React.useMemo(() => new Map(injectedTabs.map((tab) => [tab.id, tab.render])), [injectedTabs])
331
-
332
313
  const injectedTabIds = React.useMemo(() => injectedTabs.map((tab) => tab.id), [injectedTabs])
333
314
  const initialTab = React.useMemo(
334
315
  () => resolveLegacyTab(searchParams?.get('tab'), injectedTabIds),
@@ -530,6 +511,7 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
530
511
 
531
512
  {/* Persistent person header */}
532
513
  <PersonDetailHeader
514
+ actions={<InjectionSpot spotId={extensionPoints.hosts.personHeaderActions.spotId} context={injectionContext} data={data} disabled={isSaving} />}
533
515
  data={data}
534
516
  onTagsChange={handleTagsChange}
535
517
  tagsSectionControllerRef={tagsSectionControllerRef}
@@ -554,6 +536,7 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
554
536
  />
555
537
 
556
538
  {/* Zone content shared between desktop (CollapsibleZoneLayout) and mobile (MobilePersonDetail). */}
539
+ <CustomerDetailSidebar spotId={extensionPoints.hosts.personSidebar.spotId} context={injectionContext} data={data} onDataChange={setData} disabled={isSaving}>
557
540
  {(() => {
558
541
  const zone1Content = (
559
542
  <div ref={formWrapperRef}>
@@ -561,6 +544,10 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
561
544
  embedded
562
545
  trackDirtyWhenEmbedded
563
546
  injectionSpotId={extensionPoints.hosts.personForm.spotId}
547
+ entityId="customers.person"
548
+ resourceKind="customers.person"
549
+ resourceId={personId}
550
+ injectionGroupAliases={{ details: 'personalData' }}
564
551
  entityIds={[E.customers.customer_entity, E.customers.customer_person_profile]}
565
552
  schema={formSchema}
566
553
  fields={fields}
@@ -580,6 +567,7 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
580
567
  <PersonDetailTabs
581
568
  activeTab={activeTab}
582
569
  onTabChange={handleTabChange}
570
+ isLoadingInjectedTabs={injectedTabsLoading}
583
571
  injectedTabs={injectedTabs.map((tab) => ({ id: tab.id, label: tab.label }))}
584
572
  activitiesCount={interactionCount}
585
573
  dealsCount={dealCount}
@@ -591,8 +579,6 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
591
579
  <div className="min-w-0">
592
580
  {(() => {
593
581
  // Injected tab content
594
- const injected = injectedTabMap.get(activeTab)
595
- if (injected) return injected()
596
582
 
597
583
  if (activeTab === 'activities') {
598
584
  return (
@@ -719,6 +705,7 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
719
705
 
720
706
  return null
721
707
  })()}
708
+ {injectedTabMap.get(activeTab)?.()}
722
709
  </div>
723
710
  </PersonDetailTabs>
724
711
  )
@@ -740,6 +727,7 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
740
727
  />
741
728
  )
742
729
  })()}
730
+ </CustomerDetailSidebar>
743
731
 
744
732
  {/* UMES footer injection */}
745
733
  <InjectionSpot spotId={extensionPoints.hosts.personFooter.spotId} context={injectionContext} data={data} />
@@ -29,6 +29,7 @@ type CompanyDetailHeaderProps = {
29
29
  onDelete: () => Promise<void>
30
30
  isDirty: boolean
31
31
  isSaving: boolean
32
+ actions?: React.ReactNode
32
33
  onFocusField?: (fieldName: string) => void
33
34
  onDataReload?: () => void
34
35
  }
@@ -57,6 +58,7 @@ export function CompanyDetailHeader({
57
58
  onDelete,
58
59
  isDirty,
59
60
  isSaving,
61
+ actions,
60
62
  onFocusField,
61
63
  onDataReload,
62
64
  }: CompanyDetailHeaderProps) {
@@ -199,6 +201,7 @@ export function CompanyDetailHeader({
199
201
  {/* Right side: actions */}
200
202
  <div className="flex w-full shrink-0 flex-col items-start gap-3 sm:w-auto sm:items-end">
201
203
  <div className="flex w-full flex-wrap items-center justify-start gap-2 sm:w-auto sm:justify-end">
204
+ {actions}
202
205
  <SendObjectMessageDialog
203
206
  object={{
204
207
  entityModule: 'customers',
@@ -17,6 +17,8 @@ import { registerComponent } from '@open-mercato/shared/modules/widgets/componen
17
17
  import { useRegisteredComponent } from '@open-mercato/ui/backend/injection/useRegisteredComponent'
18
18
  import { useDealsAccess } from './useDealsAccess'
19
19
 
20
+ export const COMPANY_DETAIL_TAB_IDS = ['people', 'deals', 'activity-log', 'changelog', 'files'] as const
21
+
20
22
  export type CompanyTabId =
21
23
  | 'people'
22
24
  | 'deals'
@@ -38,6 +40,7 @@ export type CompanyDetailTabsProps = {
38
40
  activeTab: CompanyTabId
39
41
  onTabChange: (tab: CompanyTabId) => void
40
42
  injectedTabs?: Array<{ id: string; label: string; priority?: number }>
43
+ isLoadingInjectedTabs?: boolean
41
44
  hiddenTabIds?: string[]
42
45
  peopleCount?: number
43
46
  dealsCount?: number
@@ -57,10 +60,11 @@ const LEGACY_TAB_MAP: Record<string, CompanyTabId> = {
57
60
  analysis: 'people',
58
61
  }
59
62
 
60
- export function resolveLegacyTab(tab: string | null | undefined): CompanyTabId {
63
+ export function resolveLegacyTab(tab: string | null | undefined, knownTabIds?: Iterable<string>): CompanyTabId {
61
64
  if (!tab) return 'people'
62
65
  if (LEGACY_TAB_MAP[tab]) return LEGACY_TAB_MAP[tab]
63
- return tab as CompanyTabId
66
+ if ((COMPANY_DETAIL_TAB_IDS as readonly string[]).includes(tab) || (knownTabIds && new Set(knownTabIds).has(tab))) return tab
67
+ return 'people'
64
68
  }
65
69
 
66
70
  function formatTabCount(count: number): string | number | undefined {
@@ -72,6 +76,7 @@ function DefaultCompanyDetailTabs({
72
76
  activeTab,
73
77
  onTabChange,
74
78
  injectedTabs = [],
79
+ isLoadingInjectedTabs = false,
75
80
  hiddenTabIds = [],
76
81
  peopleCount = 0,
77
82
  dealsCount = 0,
@@ -81,7 +86,7 @@ function DefaultCompanyDetailTabs({
81
86
  children,
82
87
  }: CompanyDetailTabsProps) {
83
88
  const t = useT()
84
- const { canViewDeals } = useDealsAccess()
89
+ const { canViewDeals, isReady: isDealsAccessReady } = useDealsAccess()
85
90
 
86
91
  const builtInTabs: TabDef[] = React.useMemo(
87
92
  () => [
@@ -127,13 +132,19 @@ function DefaultCompanyDetailTabs({
127
132
  const hidden = new Set(hiddenTabIds)
128
133
  return [
129
134
  ...builtInTabs,
130
- ...injectedTabs.map((tab) => ({
135
+ ...injectedTabs.filter((tab, index) => !builtInTabs.some((native) => native.id === tab.id) && injectedTabs.findIndex((candidate) => candidate.id === tab.id) === index).map((tab) => ({
131
136
  id: tab.id as CompanyTabId,
132
137
  label: tab.label,
133
138
  })),
134
139
  ].filter((tab) => !hidden.has(tab.id))
135
140
  }, [builtInTabs, hiddenTabIds, injectedTabs])
136
141
 
142
+ React.useEffect(() => {
143
+ if (isLoadingInjectedTabs || !isDealsAccessReady || allTabs.some((tab) => tab.id === activeTab)) return
144
+ const fallback = allTabs[0]?.id
145
+ if (fallback) onTabChange(fallback)
146
+ }, [activeTab, allTabs, isLoadingInjectedTabs, onTabChange, isDealsAccessReady])
147
+
137
148
  return (
138
149
  <div>
139
150
  {/* Tab navigation */}
@@ -4,7 +4,7 @@ import * as React from 'react'
4
4
  import { Building2 } from 'lucide-react'
5
5
  import { useT } from '@open-mercato/shared/lib/i18n/context'
6
6
  import { useOrganizationScopeDetail } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
7
- import { CrudForm, type CrudField } from '@open-mercato/ui/backend/CrudForm'
7
+ import { CrudForm, type CrudField, type CrudFormSubmitResult } from '@open-mercato/ui/backend/CrudForm'
8
8
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
9
9
  import { createCrud } from '@open-mercato/ui/backend/utils/crud'
10
10
  import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
@@ -12,6 +12,7 @@ import { Button } from '@open-mercato/ui/primitives/button'
12
12
  import { Badge } from '@open-mercato/ui/primitives/badge'
13
13
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
14
14
  import { E } from '#generated/entities.ids.generated'
15
+ import { extensionPoints } from '../../extension-points'
15
16
  import {
16
17
  buildPersonPayload,
17
18
  createPersonFormFields,
@@ -134,22 +135,27 @@ export function CreatePersonDialog({
134
135
  ? await runGuardedMutation(operation, payload)
135
136
  : await operation()
136
137
 
138
+ const newId = response?.result?.id ?? response?.result?.entityId ?? ''
137
139
  flash(
138
140
  companyId
139
141
  ? t('customers.people.createDialog.success', 'Person created and linked to company')
140
142
  : t('customers.people.createDialog.successPlain', 'Person created'),
141
143
  'success',
142
144
  )
143
- const newId = response?.result?.id ?? response?.result?.entityId ?? ''
145
+ return newId ? { resourceId: newId } : undefined
146
+ }, [companyId, organizationId, runGuardedMutation, t])
147
+
148
+ const handleSubmitSuccess = React.useCallback(async (values: PersonFormValues, result: CrudFormSubmitResult | void) => {
149
+ const payload = buildPersonPayload(values, organizationId)
144
150
  const displayNameFromPayload = typeof payload.displayName === 'string' ? payload.displayName : ''
145
151
  onPersonCreated?.({
146
- id: newId,
152
+ id: result?.resourceId ?? '',
147
153
  displayName: displayNameFromPayload,
148
154
  companyId: companyId ?? null,
149
155
  companyName: companyName ?? null,
150
156
  })
151
157
  onClose()
152
- }, [companyId, companyName, onClose, onPersonCreated, organizationId, runGuardedMutation, t])
158
+ }, [companyId, companyName, onClose, onPersonCreated, organizationId])
153
159
 
154
160
  const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
155
161
  if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
@@ -179,6 +185,10 @@ export function CreatePersonDialog({
179
185
  <CrudForm<PersonFormValues>
180
186
  key={`${companyId ?? 'no-company'}:${formInstanceKey}`}
181
187
  embedded
188
+ injectionSpotId={extensionPoints.hosts.personForm.spotId}
189
+ legacyInjectionSpotId="crud-form:customers.customer_entity"
190
+ entityId="customers.person"
191
+ resourceKind="customers.person"
182
192
  entityIds={[E.customers.customer_entity, E.customers.customer_person_profile]}
183
193
  fields={fields}
184
194
  groups={groups}
@@ -186,6 +196,7 @@ export function CreatePersonDialog({
186
196
  submitLabel={t('customers.people.createDialog.submit', 'Create person')}
187
197
  schema={formSchema}
188
198
  onSubmit={handleSubmit}
199
+ onSubmitSuccess={handleSubmitSuccess}
189
200
  extraActions={(
190
201
  <Button type="button" variant="outline" onClick={onClose}>
191
202
  {t('customers.people.createDialog.cancel', 'Cancel')}
@@ -0,0 +1,41 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { InjectionSpot, useInjectionWidgets } from '@open-mercato/ui/backend/injection/InjectionSpot'
5
+
6
+ export function CustomerDetailSidebar<TData>({ spotId, context, data, onDataChange, disabled, children }: {
7
+ spotId: string
8
+ context: unknown
9
+ data: TData
10
+ onDataChange?: (data: TData) => void
11
+ disabled?: boolean
12
+ children: React.ReactNode
13
+ }) {
14
+ const { widgets } = useInjectionWidgets(spotId, { context, triggerOnLoad: true })
15
+ const sidebarRef = React.useRef<HTMLElement>(null)
16
+ const [hasContent, setHasContent] = React.useState(false)
17
+ React.useLayoutEffect(() => {
18
+ const sidebar = sidebarRef.current
19
+ if (!sidebar) {
20
+ setHasContent(false)
21
+ return
22
+ }
23
+ const update = () => setHasContent(Array.from(sidebar.childNodes).some((node) => (
24
+ node.nodeType === Node.ELEMENT_NODE || (node.nodeType === Node.TEXT_NODE && Boolean(node.textContent?.trim()))
25
+ )))
26
+ update()
27
+ const observer = new MutationObserver(update)
28
+ observer.observe(sidebar, { childList: true, subtree: true, characterData: true })
29
+ return () => observer.disconnect()
30
+ }, [widgets, data])
31
+ return (
32
+ <div className="flex min-w-0 flex-col gap-4 lg:flex-row" data-customer-detail-layout>
33
+ <div className="min-w-0 flex-1">{children}</div>
34
+ {widgets.length ? (
35
+ <aside ref={sidebarRef} className={hasContent ? 'w-full space-y-4 lg:w-80 lg:shrink-0' : 'hidden'} data-customer-detail-sidebar={spotId}>
36
+ <InjectionSpot spotId={spotId} context={context} data={data} onDataChange={onDataChange} disabled={disabled} widgetsOverride={widgets} />
37
+ </aside>
38
+ ) : null}
39
+ </div>
40
+ )
41
+ }
@@ -45,6 +45,7 @@ type DealDetailHeaderProps = {
45
45
  onDelete: () => Promise<void> | void
46
46
  isDirty: boolean
47
47
  isSaving: boolean
48
+ actions?: React.ReactNode
48
49
  }
49
50
 
50
51
  function formatCurrency(amount: string | null, currency: string | null): string | null {
@@ -196,6 +197,7 @@ export function DealDetailHeader({
196
197
  onDelete,
197
198
  isDirty,
198
199
  isSaving,
200
+ actions,
199
201
  }: DealDetailHeaderProps) {
200
202
  const t = useT()
201
203
  const amountLabel = React.useMemo(
@@ -304,7 +306,8 @@ export function DealDetailHeader({
304
306
  </div>
305
307
  </div>
306
308
 
307
- <div className="flex items-center gap-2 xl:justify-end">
309
+ <div className="flex flex-wrap items-center gap-2 xl:justify-end">
310
+ {actions}
308
311
  {canMoveStage ? (
309
312
  <StageActionButton
310
313
  stageOptions={stageOptions}
@@ -7,6 +7,8 @@ import { registerComponent } from '@open-mercato/shared/modules/widgets/componen
7
7
  import { useRegisteredComponent } from '@open-mercato/ui/backend/injection/useRegisteredComponent'
8
8
  import { Tabs, TabsList, TabsTrigger } from '@open-mercato/ui/primitives/tabs'
9
9
 
10
+ export const DEAL_DETAIL_TAB_IDS = ['activities', 'people', 'companies', 'notes', 'files', 'changelog'] as const
11
+
10
12
  export type DealTabId =
11
13
  | 'activities'
12
14
  | 'people'
@@ -29,6 +31,7 @@ export type DealDetailTabsProps = {
29
31
  activeTab: DealTabId
30
32
  onTabChange: (tab: DealTabId) => void
31
33
  injectedTabs?: Array<{ id: string; label: string }>
34
+ isLoadingInjectedTabs?: boolean
32
35
  hiddenTabIds?: string[]
33
36
  peopleCount?: number
34
37
  companiesCount?: number
@@ -53,6 +56,7 @@ function DefaultDealDetailTabs({
53
56
  activeTab,
54
57
  onTabChange,
55
58
  injectedTabs = [],
59
+ isLoadingInjectedTabs = false,
56
60
  hiddenTabIds = [],
57
61
  peopleCount = 0,
58
62
  companiesCount = 0,
@@ -103,13 +107,19 @@ function DefaultDealDetailTabs({
103
107
  const hidden = new Set(hiddenTabIds)
104
108
  return [
105
109
  ...builtInTabs,
106
- ...injectedTabs.map((tab) => ({
110
+ ...injectedTabs.filter((tab, index) => !builtInTabs.some((native) => native.id === tab.id) && injectedTabs.findIndex((candidate) => candidate.id === tab.id) === index).map((tab) => ({
107
111
  id: tab.id as DealTabId,
108
112
  label: tab.label,
109
113
  })),
110
114
  ].filter((tab) => !hidden.has(tab.id))
111
115
  }, [builtInTabs, hiddenTabIds, injectedTabs])
112
116
 
117
+ React.useEffect(() => {
118
+ if (isLoadingInjectedTabs || allTabs.some((tab) => tab.id === activeTab)) return
119
+ const fallback = allTabs[0]?.id
120
+ if (fallback) onTabChange(fallback)
121
+ }, [activeTab, allTabs, isLoadingInjectedTabs, onTabChange])
122
+
113
123
  return (
114
124
  <div>
115
125
  <Tabs
@@ -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 { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -1226,6 +1227,7 @@ export function DealForm({
1226
1227
  }
1227
1228
 
1228
1229
  return {
1230
+ ...extractResponseEnricherNamespaces(initialValues ?? {}),
1229
1231
  id: typeof initialValues?.id === 'string' ? initialValues.id : undefined,
1230
1232
  title: initialValues?.title ?? '',
1231
1233
  status: initialValues?.status ?? '',
@@ -1326,6 +1328,9 @@ export function DealForm({
1326
1328
  schema={schema}
1327
1329
  fields={baseFields}
1328
1330
  groups={groups}
1331
+ entityId="customers.deal"
1332
+ resourceKind="customers.deal"
1333
+ resourceId={typeof initialValues?.id === 'string' ? initialValues.id : undefined}
1329
1334
  entityIds={DEAL_ENTITY_IDS}
1330
1335
  initialValues={embeddedInitialValues}
1331
1336
  onSubmit={handleSubmit}
@@ -31,6 +31,7 @@ type PersonDetailHeaderProps = {
31
31
  onDelete: () => Promise<void>
32
32
  isDirty: boolean
33
33
  isSaving: boolean
34
+ actions?: React.ReactNode
34
35
  /** Callback to focus a specific field in the Zone 1 CrudForm by field name. */
35
36
  onFocusField?: (fieldName: string) => void
36
37
  /**
@@ -86,6 +87,7 @@ export function PersonDetailHeader({
86
87
  onDelete,
87
88
  isDirty,
88
89
  isSaving,
90
+ actions,
89
91
  onFocusField,
90
92
  onOpenCompaniesTab,
91
93
  onDataReload,
@@ -246,6 +248,7 @@ export function PersonDetailHeader({
246
248
 
247
249
  {/* Right side: actions */}
248
250
  <div className="flex w-full shrink-0 items-center justify-start gap-2 sm:w-auto sm:justify-end">
251
+ {actions}
249
252
  <SendObjectMessageDialog
250
253
  object={{
251
254
  entityModule: 'customers',
@@ -19,6 +19,8 @@ import type { SectionAction } from '@open-mercato/ui/backend/detail'
19
19
  import { registerComponent } from '@open-mercato/shared/modules/widgets/component-registry'
20
20
  import { useRegisteredComponent } from '@open-mercato/ui/backend/injection/useRegisteredComponent'
21
21
 
22
+ export const PERSON_DETAIL_TAB_IDS = ['activities', 'emails', 'deals', 'companies', 'addresses', 'tasks', 'changelog', 'files'] as const
23
+
22
24
  export type PersonTabId =
23
25
  | 'activities'
24
26
  | 'emails'
@@ -43,6 +45,7 @@ export type PersonDetailTabsProps = {
43
45
  activeTab: PersonTabId
44
46
  onTabChange: (tab: PersonTabId) => void
45
47
  injectedTabs?: Array<{ id: string; label: string }>
48
+ isLoadingInjectedTabs?: boolean
46
49
  hiddenTabIds?: string[]
47
50
  activitiesCount?: number
48
51
  dealsCount?: number
@@ -72,6 +75,7 @@ function DefaultPersonDetailTabs({
72
75
  activeTab,
73
76
  onTabChange,
74
77
  injectedTabs = [],
78
+ isLoadingInjectedTabs = false,
75
79
  hiddenTabIds = [],
76
80
  activitiesCount = 0,
77
81
  dealsCount = 0,
@@ -141,13 +145,19 @@ function DefaultPersonDetailTabs({
141
145
  const hidden = new Set(hiddenTabIds)
142
146
  return [
143
147
  ...builtInTabs,
144
- ...injectedTabs.map((tab) => ({
148
+ ...injectedTabs.filter((tab, index) => !builtInTabs.some((native) => native.id === tab.id) && injectedTabs.findIndex((candidate) => candidate.id === tab.id) === index).map((tab) => ({
145
149
  id: tab.id as PersonTabId,
146
150
  label: tab.label,
147
151
  })),
148
152
  ].filter((tab) => !hidden.has(tab.id))
149
153
  }, [builtInTabs, hiddenTabIds, injectedTabs])
150
154
 
155
+ React.useEffect(() => {
156
+ if (isLoadingInjectedTabs || allTabs.some((tab) => tab.id === activeTab)) return
157
+ const fallback = allTabs[0]?.id
158
+ if (fallback) onTabChange(fallback)
159
+ }, [activeTab, allTabs, isLoadingInjectedTabs, onTabChange])
160
+
151
161
  return (
152
162
  <div>
153
163
  {/* Tab navigation — full width above both zones */}