@open-mercato/core 0.10.1-develop.7836.1.0ef0f453c6 → 0.10.1-develop.7847.1.7d83dc5794

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 (136) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/api_keys/api/keys/route.js +15 -1
  3. package/dist/modules/api_keys/api/keys/route.js.map +2 -2
  4. package/dist/modules/auth/api/users/route.js +16 -1
  5. package/dist/modules/auth/api/users/route.js.map +2 -2
  6. package/dist/modules/auth/backend/users/page.js +13 -2
  7. package/dist/modules/auth/backend/users/page.js.map +2 -2
  8. package/dist/modules/catalog/commands/products.js +4 -2
  9. package/dist/modules/catalog/commands/products.js.map +2 -2
  10. package/dist/modules/customer_accounts/api/admin/roles.js +25 -2
  11. package/dist/modules/customer_accounts/api/admin/roles.js.map +2 -2
  12. package/dist/modules/customer_accounts/api/admin/users.js +24 -7
  13. package/dist/modules/customer_accounts/api/admin/users.js.map +2 -2
  14. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +77 -15
  15. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
  16. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js +12 -1
  17. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js.map +2 -2
  18. package/dist/modules/customer_accounts/data/validators.js +2 -1
  19. package/dist/modules/customer_accounts/data/validators.js.map +2 -2
  20. package/dist/modules/customer_accounts/lib/adminOrganizationScope.js +46 -0
  21. package/dist/modules/customer_accounts/lib/adminOrganizationScope.js.map +7 -0
  22. package/dist/modules/customers/api/customer-dictionary-entries/route.js +86 -0
  23. package/dist/modules/customers/api/customer-dictionary-entries/route.js.map +7 -0
  24. package/dist/modules/customers/api/dictionaries/[kind]/route.js +29 -7
  25. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  26. package/dist/modules/customers/cli.js +28 -110
  27. package/dist/modules/customers/cli.js.map +3 -3
  28. package/dist/modules/customers/components/DictionarySettings.js +1 -1
  29. package/dist/modules/customers/components/DictionarySettings.js.map +1 -1
  30. package/dist/modules/customers/components/detail/ManageTagsDialog.js +1 -1
  31. package/dist/modules/customers/components/detail/ManageTagsDialog.js.map +2 -2
  32. package/dist/modules/customers/components/detail/TaskDialog.js +14 -1
  33. package/dist/modules/customers/components/detail/TaskDialog.js.map +2 -2
  34. package/dist/modules/customers/components/detail/TaskForm.js +1 -16
  35. package/dist/modules/customers/components/detail/TaskForm.js.map +2 -2
  36. package/dist/modules/customers/components/detail/TasksSection.js +38 -10
  37. package/dist/modules/customers/components/detail/TasksSection.js.map +2 -2
  38. package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js +12 -5
  39. package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js.map +2 -2
  40. package/dist/modules/customers/components/detail/hooks/useInteractions.js +3 -13
  41. package/dist/modules/customers/components/detail/hooks/useInteractions.js.map +2 -2
  42. package/dist/modules/customers/lib/dictionaryDefaults.js +134 -0
  43. package/dist/modules/customers/lib/dictionaryDefaults.js.map +7 -0
  44. package/dist/modules/customers/lib/dictionaryLabels.js +59 -0
  45. package/dist/modules/customers/lib/dictionaryLabels.js.map +7 -0
  46. package/dist/modules/customers/lib/interactionCompatibility.js +1 -0
  47. package/dist/modules/customers/lib/interactionCompatibility.js.map +2 -2
  48. package/dist/modules/customers/translations.js +9 -0
  49. package/dist/modules/customers/translations.js.map +7 -0
  50. package/dist/modules/directory/api/organization-branding/route.js +6 -9
  51. package/dist/modules/directory/api/organization-branding/route.js.map +2 -2
  52. package/dist/modules/directory/backend/directory/branding/page.js +15 -3
  53. package/dist/modules/directory/backend/directory/branding/page.js.map +2 -2
  54. package/dist/modules/eudr/index.js +13 -0
  55. package/dist/modules/eudr/index.js.map +2 -2
  56. package/dist/modules/staff/api/timesheets/access-requests/route.js +11 -5
  57. package/dist/modules/staff/api/timesheets/access-requests/route.js.map +2 -2
  58. package/dist/modules/staff/api/timesheets/time-projects/route.js +19 -12
  59. package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
  60. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js +21 -12
  61. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js.map +2 -2
  62. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +14 -9
  63. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  64. package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js +31 -10
  65. package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js.map +2 -2
  66. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js +64 -0
  67. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js.map +7 -0
  68. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js +13 -0
  69. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js.map +7 -0
  70. package/dist/modules/sync_excel/index.js +11 -1
  71. package/dist/modules/sync_excel/index.js.map +2 -2
  72. package/dist/modules/translations/di.js +2 -0
  73. package/dist/modules/translations/di.js.map +2 -2
  74. package/dist/modules/wms/api/listEnrichers.js +3 -1
  75. package/dist/modules/wms/api/listEnrichers.js.map +2 -2
  76. package/dist/modules/wms/index.js +1 -1
  77. package/dist/modules/wms/index.js.map +2 -2
  78. package/package.json +7 -7
  79. package/src/modules/api_keys/api/keys/route.ts +15 -1
  80. package/src/modules/api_keys/i18n/de.json +1 -0
  81. package/src/modules/api_keys/i18n/en.json +1 -0
  82. package/src/modules/api_keys/i18n/es.json +1 -0
  83. package/src/modules/api_keys/i18n/ko.json +1 -0
  84. package/src/modules/api_keys/i18n/pl.json +1 -0
  85. package/src/modules/auth/api/users/route.ts +18 -1
  86. package/src/modules/auth/backend/users/page.tsx +14 -2
  87. package/src/modules/auth/i18n/de.json +1 -0
  88. package/src/modules/auth/i18n/en.json +1 -0
  89. package/src/modules/auth/i18n/es.json +1 -0
  90. package/src/modules/auth/i18n/ko.json +1 -0
  91. package/src/modules/auth/i18n/pl.json +1 -0
  92. package/src/modules/catalog/commands/products.ts +4 -1
  93. package/src/modules/customer_accounts/api/admin/roles.ts +26 -2
  94. package/src/modules/customer_accounts/api/admin/users.ts +26 -7
  95. package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +96 -13
  96. package/src/modules/customer_accounts/backend/customer_accounts/users/page.tsx +9 -1
  97. package/src/modules/customer_accounts/data/validators.ts +1 -0
  98. package/src/modules/customer_accounts/i18n/de.json +10 -0
  99. package/src/modules/customer_accounts/i18n/en.json +10 -0
  100. package/src/modules/customer_accounts/i18n/es.json +10 -0
  101. package/src/modules/customer_accounts/i18n/ko.json +10 -0
  102. package/src/modules/customer_accounts/i18n/pl.json +10 -0
  103. package/src/modules/customer_accounts/lib/adminOrganizationScope.ts +71 -0
  104. package/src/modules/customers/api/customer-dictionary-entries/route.ts +82 -0
  105. package/src/modules/customers/api/dictionaries/[kind]/route.ts +29 -6
  106. package/src/modules/customers/cli.ts +23 -124
  107. package/src/modules/customers/components/DictionarySettings.tsx +1 -1
  108. package/src/modules/customers/components/detail/ManageTagsDialog.tsx +1 -1
  109. package/src/modules/customers/components/detail/TaskDialog.tsx +15 -1
  110. package/src/modules/customers/components/detail/TaskForm.tsx +1 -17
  111. package/src/modules/customers/components/detail/TasksSection.tsx +44 -14
  112. package/src/modules/customers/components/detail/hooks/useCustomerDictionary.ts +14 -8
  113. package/src/modules/customers/components/detail/hooks/useInteractions.ts +3 -4
  114. package/src/modules/customers/i18n/de.json +81 -0
  115. package/src/modules/customers/i18n/en.json +81 -0
  116. package/src/modules/customers/i18n/es.json +81 -0
  117. package/src/modules/customers/i18n/ko.json +81 -0
  118. package/src/modules/customers/i18n/pl.json +81 -0
  119. package/src/modules/customers/lib/dictionaryDefaults.ts +137 -0
  120. package/src/modules/customers/lib/dictionaryLabels.ts +73 -0
  121. package/src/modules/customers/lib/interactionCompatibility.ts +1 -0
  122. package/src/modules/customers/translations.ts +5 -0
  123. package/src/modules/directory/api/organization-branding/route.ts +5 -8
  124. package/src/modules/directory/backend/directory/branding/page.tsx +15 -3
  125. package/src/modules/eudr/index.ts +13 -0
  126. package/src/modules/staff/api/timesheets/access-requests/route.ts +13 -14
  127. package/src/modules/staff/api/timesheets/time-projects/route.ts +22 -13
  128. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.tsx +25 -14
  129. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +14 -13
  130. package/src/modules/staff/lib/time-tracking-ui/NoProjectAccess.tsx +30 -8
  131. package/src/modules/staff/lib/time-tracking-ui/projectAccessScope.ts +102 -0
  132. package/src/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.ts +15 -0
  133. package/src/modules/sync_excel/index.ts +10 -0
  134. package/src/modules/translations/di.ts +2 -0
  135. package/src/modules/wms/api/listEnrichers.ts +5 -2
  136. package/src/modules/wms/index.ts +1 -1
@@ -33,6 +33,8 @@ import { formatCurrency } from '@open-mercato/ui/utils/format'
33
33
  import { KpiCard, Sparkline } from '@open-mercato/ui/backend/charts'
34
34
  import { ProjectTeamDrawer } from '../../../../../lib/time-tracking-ui/ProjectTeamDrawer'
35
35
  import { NoProjectAccess } from '../../../../../lib/time-tracking-ui/NoProjectAccess'
36
+ import { useProjectAccessScope } from '../../../../../lib/time-tracking-ui/projectAccessScope'
37
+ import { readResolvedProjectAccessScope } from '../../../../../lib/time-tracking-ui/projectAccessScopeHeaders'
36
38
  import { ProjectBudgetCell } from '../../../../../lib/timesheets-projects-ui/ProjectBudgetCell'
37
39
  import { computeBudgetBurn } from '../../../../../lib/timesheets-projects/budgetBurn'
38
40
  import { resolveProjectColorHex } from '../../../../../lib/timesheets-ui/colors'
@@ -177,6 +179,7 @@ export default function TimesheetProjectDetailPage({ params }: { params?: { id?:
177
179
  const translateRef = React.useRef(t)
178
180
  React.useEffect(() => { translateRef.current = t }, [t])
179
181
  const scopeVersion = useOrganizationScopeVersion()
182
+ const { scopeKey: projectAccessScopeKey, resolvedScope, resolveScope } = useProjectAccessScope(projectId)
180
183
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
181
184
  const router = useRouter()
182
185
  const pathname = usePathname()
@@ -186,13 +189,9 @@ export default function TimesheetProjectDetailPage({ params }: { params?: { id?:
186
189
  const [loading, setLoading] = React.useState(true)
187
190
  const [error, setError] = React.useState<string | null>(null)
188
191
  const [isNotFound, setIsNotFound] = React.useState(false)
189
- // Keyed by project id so a route change to another project never shows the
190
- // previous project's guard (with a live request button) while the new one loads.
191
- const [accessDeniedProjectId, setAccessDeniedProjectId] = React.useState<string | null>(null)
192
- const accessDenied = accessDeniedProjectId !== null && accessDeniedProjectId === projectId
193
- // The id the last finished load was for. Until it matches the route, the page
194
- // is still loading — never the previous project's error or empty state.
195
- const [settledProjectId, setSettledProjectId] = React.useState<string | null>(null)
192
+ const [accessDeniedScopeKey, setAccessDeniedScopeKey] = React.useState<string | null>(null)
193
+ const [loadedScopeKey, setLoadedScopeKey] = React.useState<string | null>(null)
194
+ const accessDenied = accessDeniedScopeKey === projectAccessScopeKey
196
195
 
197
196
  const [employees, setEmployees] = React.useState<EmployeeAssignment[]>([])
198
197
  const [employeesLoading, setEmployeesLoading] = React.useState(false)
@@ -407,13 +406,14 @@ export default function TimesheetProjectDetailPage({ params }: { params?: { id?:
407
406
  const call = await apiCall<ProjectResponse>(
408
407
  `/api/staff/timesheets/time-projects?${queryParams.toString()}`,
409
408
  )
409
+ if (!cancelled) resolveScope(readResolvedProjectAccessScope(call.response))
410
410
  // Screen 17: the route answers 404 with this discriminator for a project
411
411
  // the caller is not a member of, without naming it.
412
412
  if (call.status === 404 && readDenialReason(call.result) === NO_PROJECT_ACCESS_REASON) {
413
- if (!cancelled) setAccessDeniedProjectId(projectId ?? null)
413
+ if (!cancelled) setAccessDeniedScopeKey(projectAccessScopeKey)
414
414
  return
415
415
  }
416
- if (!cancelled) setAccessDeniedProjectId(null)
416
+ if (!cancelled) setAccessDeniedScopeKey(null)
417
417
  if (!call.ok) {
418
418
  throw new Error(translateRef.current('staff.timesheets.projects.errors.load', 'Failed to load project.'))
419
419
  }
@@ -426,18 +426,19 @@ export default function TimesheetProjectDetailPage({ params }: { params?: { id?:
426
426
  if (!cancelled) setProject(record)
427
427
  } catch (loadError) {
428
428
  if (!cancelled) {
429
+ setAccessDeniedScopeKey(null)
429
430
  setError(loadError instanceof Error ? loadError.message : translateRef.current('staff.timesheets.projects.errors.load', 'Failed to load project.'))
430
431
  }
431
432
  } finally {
432
433
  if (!cancelled) {
434
+ setLoadedScopeKey(projectAccessScopeKey)
433
435
  setLoading(false)
434
- setSettledProjectId(projectId ?? null)
435
436
  }
436
437
  }
437
438
  }
438
439
  loadProject()
439
440
  return () => { cancelled = true }
440
- }, [projectId, scopeVersion])
441
+ }, [projectId, scopeVersion, projectAccessScopeKey, resolveScope])
441
442
 
442
443
  // --- Load employees with name resolution ---
443
444
  const loadEmployees = React.useCallback(async () => {
@@ -709,10 +710,10 @@ export default function TimesheetProjectDetailPage({ params }: { params?: { id?:
709
710
  // resolving late) keeps the guard mounted instead of resetting a request the
710
711
  // user has just sent.
711
712
  if (accessDenied) {
712
- return <NoProjectAccess timeProjectId={projectId} />
713
+ return <NoProjectAccess timeProjectId={projectId} resolvedScope={resolvedScope} />
713
714
  }
714
715
 
715
- if (loading || settledProjectId !== (projectId ?? null)) {
716
+ if (loading || loadedScopeKey !== projectAccessScopeKey) {
716
717
  return (
717
718
  <Page>
718
719
  <PageBody>
@@ -10,6 +10,8 @@ import { flash } from '@open-mercato/ui/backend/FlashMessages'
10
10
  import { apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
11
11
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
12
12
  import { useT } from '@open-mercato/shared/lib/i18n/context'
13
+ import { useProjectAccessScope } from './projectAccessScope'
14
+ import type { ResolvedProjectAccessScope } from './projectAccessScopeHeaders'
13
15
 
14
16
  export const MY_WORK_HREF = '/backend/staff/time-tracking'
15
17
  export const ACCESS_REQUEST_ENDPOINT = '/api/staff/timesheets/access-requests'
@@ -22,36 +24,52 @@ export type AccessRequestButtonProps = {
22
24
  * project exists to a user who has no right to it.
23
25
  */
24
26
  timeProjectId?: string | null
27
+ resolvedScope?: ResolvedProjectAccessScope | null
25
28
  label: string
26
29
  sentLabel: string
27
30
  variant?: 'default' | 'outline'
28
31
  }
29
32
 
30
- export function AccessRequestButton({
33
+ export function AccessRequestButton(props: AccessRequestButtonProps) {
34
+ const { scopeKey, isCurrentScope } = useProjectAccessScope(props.timeProjectId, props.resolvedScope)
35
+ return <ScopedAccessRequestButton key={scopeKey} {...props} isCurrentScope={isCurrentScope} />
36
+ }
37
+
38
+ function ScopedAccessRequestButton({
31
39
  timeProjectId,
32
40
  label,
33
41
  sentLabel,
34
42
  variant = 'outline',
35
- }: AccessRequestButtonProps) {
43
+ isCurrentScope,
44
+ }: AccessRequestButtonProps & { isCurrentScope: () => boolean }) {
36
45
  const t = useT()
37
46
  const [submitting, setSubmitting] = React.useState(false)
38
47
  const [sent, setSent] = React.useState(false)
48
+ const mounted = React.useRef(true)
49
+ React.useEffect(() => {
50
+ mounted.current = true
51
+ return () => { mounted.current = false }
52
+ }, [])
39
53
  const { runMutation, retryLastMutation } = useGuardedMutation<Record<string, unknown>>({
40
54
  contextId: ACCESS_REQUEST_CONTEXT_ID,
41
55
  blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
42
56
  })
43
57
 
44
58
  const requestAccess = React.useCallback(async () => {
59
+ const isCurrent = () => mounted.current && isCurrentScope()
60
+ if (!isCurrent()) return
45
61
  const payload = timeProjectId ? { timeProjectId } : {}
46
62
  setSubmitting(true)
47
63
  try {
48
64
  await runMutation({
49
- operation: () =>
50
- apiCallOrThrow(ACCESS_REQUEST_ENDPOINT, {
65
+ operation: () => {
66
+ if (!isCurrent()) throw new Error('[internal] Access request scope changed')
67
+ return apiCallOrThrow(ACCESS_REQUEST_ENDPOINT, {
51
68
  method: 'POST',
52
69
  headers: { 'content-type': 'application/json' },
53
70
  body: JSON.stringify(payload),
54
- }),
71
+ })
72
+ },
55
73
  context: {
56
74
  formId: ACCESS_REQUEST_CONTEXT_ID,
57
75
  resourceKind: 'staff.timesheets.access_request',
@@ -59,6 +77,7 @@ export function AccessRequestButton({
59
77
  },
60
78
  mutationPayload: payload,
61
79
  })
80
+ if (!isCurrent()) return
62
81
  setSent(true)
63
82
  flash(
64
83
  t(
@@ -68,14 +87,15 @@ export function AccessRequestButton({
68
87
  'success',
69
88
  )
70
89
  } catch {
90
+ if (!isCurrent()) return
71
91
  flash(
72
92
  t('staff.time_tracking.access.request.error', 'Could not send the access request.'),
73
93
  'error',
74
94
  )
75
95
  } finally {
76
- setSubmitting(false)
96
+ if (isCurrent()) setSubmitting(false)
77
97
  }
78
- }, [retryLastMutation, runMutation, t, timeProjectId])
98
+ }, [isCurrentScope, retryLastMutation, runMutation, t, timeProjectId])
79
99
 
80
100
  return (
81
101
  <Button type="button" variant={variant} onClick={requestAccess} disabled={submitting || sent}>
@@ -87,6 +107,7 @@ export function AccessRequestButton({
87
107
 
88
108
  export type NoProjectAccessProps = {
89
109
  timeProjectId?: string | null
110
+ resolvedScope?: ResolvedProjectAccessScope | null
90
111
  /** Where the "Back to My work" action returns to. */
91
112
  myWorkHref?: string
92
113
  }
@@ -97,7 +118,7 @@ export type NoProjectAccessProps = {
97
118
  * access" and "access was revoked" (note 2); navigation stays untouched because
98
119
  * the caller still has other projects (note 3).
99
120
  */
100
- export function NoProjectAccess({ timeProjectId, myWorkHref = MY_WORK_HREF }: NoProjectAccessProps) {
121
+ export function NoProjectAccess({ timeProjectId, resolvedScope, myWorkHref = MY_WORK_HREF }: NoProjectAccessProps) {
101
122
  const t = useT()
102
123
  return (
103
124
  <Page>
@@ -121,6 +142,7 @@ export function NoProjectAccess({ timeProjectId, myWorkHref = MY_WORK_HREF }: No
121
142
  </Button>
122
143
  <AccessRequestButton
123
144
  timeProjectId={timeProjectId}
145
+ resolvedScope={resolvedScope}
124
146
  label={t('staff.time_tracking.access.denied.request', 'Request access')}
125
147
  sentLabel={t('staff.time_tracking.access.request.sent', 'Request sent')}
126
148
  />
@@ -0,0 +1,102 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
5
+ import {
6
+ parseSelectedOrganizationCookie,
7
+ parseSelectedTenantCookie,
8
+ } from '@open-mercato/core/modules/directory/utils/scopeCookies'
9
+ import type { ResolvedProjectAccessScope } from './projectAccessScopeHeaders'
10
+
11
+ export type ProjectAccessScope = {
12
+ projectId: string | null
13
+ tenantId: string | null
14
+ organizationId: string | null
15
+ }
16
+
17
+ export type ProjectAccessScopeState = {
18
+ /** Stable while the scope stays the same; changes when the project, tenant or organization changes. */
19
+ scopeKey: string
20
+ /** Reads the scope cookies again, so it also catches a change that has not been rendered yet. */
21
+ isCurrentScope: () => boolean
22
+ resolvedScope: ResolvedProjectAccessScope | null
23
+ resolveScope: (scope: ResolvedProjectAccessScope | null) => void
24
+ }
25
+
26
+ type ProjectAccessScopeBinding = {
27
+ scope: ProjectAccessScope
28
+ generation: number
29
+ resolvedScope: ResolvedProjectAccessScope | null
30
+ }
31
+
32
+ export function readProjectAccessScope(timeProjectId?: string | null): ProjectAccessScope {
33
+ const cookieHeader = typeof document === 'undefined' ? null : document.cookie
34
+ return {
35
+ projectId: timeProjectId ?? null,
36
+ tenantId: parseSelectedTenantCookie(cookieHeader),
37
+ organizationId: parseSelectedOrganizationCookie(cookieHeader),
38
+ }
39
+ }
40
+
41
+ export function bindProjectAccessScope(
42
+ bound: ProjectAccessScope,
43
+ current: ProjectAccessScope,
44
+ resolvedScope?: ResolvedProjectAccessScope | null,
45
+ ): ProjectAccessScope | null {
46
+ const continues = (boundValue: string | null, currentValue: string | null, resolvedValue?: string) =>
47
+ boundValue === currentValue || (boundValue === null && currentValue === resolvedValue)
48
+ if (bound.projectId !== current.projectId) return null
49
+ if (!continues(bound.tenantId, current.tenantId, resolvedScope?.tenantId)) return null
50
+ if (!continues(bound.organizationId, current.organizationId, resolvedScope?.organizationId)) return null
51
+ return current
52
+ }
53
+
54
+ function isSameProjectAccessScope(left: ProjectAccessScope, right: ProjectAccessScope): boolean {
55
+ return left.projectId === right.projectId
56
+ && left.tenantId === right.tenantId
57
+ && left.organizationId === right.organizationId
58
+ }
59
+
60
+ function rebindProjectAccessScope(
61
+ binding: ProjectAccessScopeBinding,
62
+ current: ProjectAccessScope,
63
+ ): ProjectAccessScopeBinding {
64
+ const continued = bindProjectAccessScope(binding.scope, current, binding.resolvedScope)
65
+ if (!continued) return { scope: current, generation: binding.generation + 1, resolvedScope: null }
66
+ if (isSameProjectAccessScope(continued, binding.scope)) return binding
67
+ return { ...binding, scope: continued }
68
+ }
69
+
70
+ export function useProjectAccessScope(
71
+ timeProjectId?: string | null,
72
+ initialResolvedScope?: ResolvedProjectAccessScope | null,
73
+ ): ProjectAccessScopeState {
74
+ useOrganizationScopeVersion()
75
+ const current = readProjectAccessScope(timeProjectId)
76
+ const [binding, setBinding] = React.useState<ProjectAccessScopeBinding>(() => ({
77
+ scope: current,
78
+ generation: 0,
79
+ resolvedScope: initialResolvedScope ?? null,
80
+ }))
81
+ const nextBinding = rebindProjectAccessScope(binding, current)
82
+ if (nextBinding !== binding) setBinding(nextBinding)
83
+ const boundScope = React.useRef(nextBinding)
84
+ boundScope.current = nextBinding
85
+ const generation = nextBinding.generation
86
+ const isCurrentScope = React.useCallback(() => {
87
+ const active = boundScope.current
88
+ return active.generation === generation
89
+ && bindProjectAccessScope(active.scope, readProjectAccessScope(timeProjectId), active.resolvedScope) !== null
90
+ }, [generation, timeProjectId])
91
+ const resolveScope = React.useCallback((resolvedScope: ResolvedProjectAccessScope | null) => {
92
+ setBinding((previous) => {
93
+ if (previous.generation !== generation) return previous
94
+ if (!bindProjectAccessScope(previous.scope, readProjectAccessScope(timeProjectId), resolvedScope)) return previous
95
+ const changed = previous.resolvedScope !== null && resolvedScope !== null
96
+ && (previous.resolvedScope.tenantId !== resolvedScope.tenantId
97
+ || previous.resolvedScope.organizationId !== resolvedScope.organizationId)
98
+ return { ...previous, resolvedScope, generation: previous.generation + (changed ? 1 : 0) }
99
+ })
100
+ }, [generation, timeProjectId])
101
+ return { scopeKey: String(generation), isCurrentScope, resolvedScope: nextBinding.resolvedScope, resolveScope }
102
+ }
@@ -0,0 +1,15 @@
1
+ export const PROJECT_SCOPE_TENANT_HEADER = 'x-om-project-scope-tenant-id'
2
+ export const PROJECT_SCOPE_ORGANIZATION_HEADER = 'x-om-project-scope-organization-id'
3
+
4
+ export type ResolvedProjectAccessScope = {
5
+ tenantId: string
6
+ organizationId: string
7
+ }
8
+
9
+ export function readResolvedProjectAccessScope(
10
+ response: { headers?: Pick<Headers, 'get'> },
11
+ ): ResolvedProjectAccessScope | null {
12
+ const tenantId = response.headers?.get(PROJECT_SCOPE_TENANT_HEADER)
13
+ const organizationId = response.headers?.get(PROJECT_SCOPE_ORGANIZATION_HEADER)
14
+ return tenantId && organizationId ? { tenantId, organizationId } : null
15
+ }
@@ -2,6 +2,16 @@ export const metadata = {
2
2
  id: 'sync_excel',
3
3
  title: 'Excel / CSV Import',
4
4
  description: 'File-upload-based CSV import foundation built on top of the data sync hub.',
5
+ /**
6
+ * `customers` is a HARD dependency: `lib/adapters/customers.ts` reads `E.customers.*` at module scope.
7
+ *
8
+ * `E` only carries a key for an ENABLED module, so with `customers` switched off that read is
9
+ * `undefined.customer_entity` and it throws while the adapter is being imported — not inside a request, where
10
+ * it could be traced. Declaring it has the generator refuse the combination before anybody starts the app.
11
+ *
12
+ * `data_sync` is the hub this is built on, named in the description and now in the manifest too.
13
+ */
14
+ requires: ['customers', 'data_sync'],
5
15
  }
6
16
 
7
17
  export default metadata
@@ -2,6 +2,7 @@ import { registerTranslatableFields } from '@open-mercato/shared/lib/localizatio
2
2
  import { registerTranslationOverlayPlugin } from '@open-mercato/shared/lib/localization/overlay-plugin'
3
3
  import { registerSupportedLocalesResolver } from '@open-mercato/shared/lib/i18n/locale-registry'
4
4
  import { translatableFields as catalogFields } from '../catalog/translations'
5
+ import { translatableFields as customerFields } from '../customers/translations'
5
6
  import { translatableFields as dictionaryFields } from '../dictionaries/translations'
6
7
  import { translatableFields as entitiesFields } from '../entities/translations'
7
8
  import { translatableFields as resourcesFields } from '../resources/translations'
@@ -19,6 +20,7 @@ registerSupportedLocalesResolver(resolveTenantSupportedLocales)
19
20
 
20
21
  export function register() {
21
22
  registerTranslatableFields(catalogFields)
23
+ registerTranslatableFields(customerFields)
22
24
  registerTranslatableFields(dictionaryFields)
23
25
  registerTranslatableFields(entitiesFields)
24
26
  registerTranslatableFields(resourcesFields)
@@ -277,7 +277,8 @@ const CUSTOMER_FIELDS = ['id', 'display_name']
277
277
  /**
278
278
  * Decorate list items with `vendor_name` when they carry a `vendor_id` column
279
279
  * referencing `customers.customer_entity` by UUID only (no ORM cross-module
280
- * relations). Mutates `payload.items` in place.
280
+ * relations). No-op when the optional `customers` module is not enabled.
281
+ * Mutates `payload.items` in place.
281
282
  */
282
283
  export async function attachVendorLabelsToListItems(
283
284
  payload: AnyListPayload,
@@ -285,11 +286,13 @@ export async function attachVendorLabelsToListItems(
285
286
  ): Promise<void> {
286
287
  const items = Array.isArray(payload?.items) ? payload.items : []
287
288
  if (items.length === 0) return
289
+ const customerEntityId = E.customers?.customer_entity
290
+ if (!customerEntityId) return
288
291
  const ids = uniqueIdsFromKeys(items, ['vendor_id'])
289
292
  if (ids.length === 0) return
290
293
  const map = (await batchLoadById(
291
294
  ctx,
292
- E.customers.customer_entity,
295
+ customerEntityId,
293
296
  ids,
294
297
  CUSTOMER_FIELDS,
295
298
  'vendor',
@@ -9,7 +9,7 @@ export const metadata: ModuleInfo = {
9
9
  author: 'Open Mercato Team',
10
10
  license: 'MIT',
11
11
  ejectable: true,
12
- requires: ['catalog', 'sales', 'feature_toggles'],
12
+ requires: ['catalog', 'customers', 'sales', 'feature_toggles'],
13
13
  }
14
14
 
15
15
  export { features } from './acl'