@open-mercato/core 0.10.1-develop.7833.1.d6cbf0e3ab → 0.10.1-develop.7841.1.af87ac614d

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/cart/api/current/lines/bulk/route.js +1 -1
  3. package/dist/modules/cart/api/current/lines/bulk/route.js.map +2 -2
  4. package/dist/modules/cart/api/current/lines/route.js +1 -1
  5. package/dist/modules/cart/api/current/lines/route.js.map +2 -2
  6. package/dist/modules/cart/commands/lines.js +5 -10
  7. package/dist/modules/cart/commands/lines.js.map +2 -2
  8. package/dist/modules/cart/lib/currentCartRoute.js +8 -0
  9. package/dist/modules/cart/lib/currentCartRoute.js.map +3 -3
  10. package/dist/modules/cart/lib/lineMutationLock.js +52 -0
  11. package/dist/modules/cart/lib/lineMutationLock.js.map +7 -0
  12. package/dist/modules/customer_accounts/api/admin/roles.js +25 -2
  13. package/dist/modules/customer_accounts/api/admin/roles.js.map +2 -2
  14. package/dist/modules/customer_accounts/api/admin/users.js +24 -7
  15. package/dist/modules/customer_accounts/api/admin/users.js.map +2 -2
  16. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +77 -15
  17. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
  18. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js +12 -1
  19. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js.map +2 -2
  20. package/dist/modules/customer_accounts/data/validators.js +2 -1
  21. package/dist/modules/customer_accounts/data/validators.js.map +2 -2
  22. package/dist/modules/customer_accounts/lib/adminOrganizationScope.js +46 -0
  23. package/dist/modules/customer_accounts/lib/adminOrganizationScope.js.map +7 -0
  24. package/dist/modules/customers/api/customer-dictionary-entries/route.js +86 -0
  25. package/dist/modules/customers/api/customer-dictionary-entries/route.js.map +7 -0
  26. package/dist/modules/customers/api/dictionaries/[kind]/route.js +29 -7
  27. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  28. package/dist/modules/customers/cli.js +28 -110
  29. package/dist/modules/customers/cli.js.map +3 -3
  30. package/dist/modules/customers/components/DictionarySettings.js +1 -1
  31. package/dist/modules/customers/components/DictionarySettings.js.map +1 -1
  32. package/dist/modules/customers/components/detail/ManageTagsDialog.js +1 -1
  33. package/dist/modules/customers/components/detail/ManageTagsDialog.js.map +2 -2
  34. package/dist/modules/customers/components/detail/TaskDialog.js +14 -1
  35. package/dist/modules/customers/components/detail/TaskDialog.js.map +2 -2
  36. package/dist/modules/customers/components/detail/TaskForm.js +1 -16
  37. package/dist/modules/customers/components/detail/TaskForm.js.map +2 -2
  38. package/dist/modules/customers/components/detail/TasksSection.js +38 -10
  39. package/dist/modules/customers/components/detail/TasksSection.js.map +2 -2
  40. package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js +12 -5
  41. package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js.map +2 -2
  42. package/dist/modules/customers/components/detail/hooks/useInteractions.js +3 -13
  43. package/dist/modules/customers/components/detail/hooks/useInteractions.js.map +2 -2
  44. package/dist/modules/customers/lib/dictionaryDefaults.js +134 -0
  45. package/dist/modules/customers/lib/dictionaryDefaults.js.map +7 -0
  46. package/dist/modules/customers/lib/dictionaryLabels.js +59 -0
  47. package/dist/modules/customers/lib/dictionaryLabels.js.map +7 -0
  48. package/dist/modules/customers/lib/interactionCompatibility.js +1 -0
  49. package/dist/modules/customers/lib/interactionCompatibility.js.map +2 -2
  50. package/dist/modules/customers/translations.js +9 -0
  51. package/dist/modules/customers/translations.js.map +7 -0
  52. package/dist/modules/eudr/index.js +13 -0
  53. package/dist/modules/eudr/index.js.map +2 -2
  54. package/dist/modules/sales/commands/documents.js +3 -3
  55. package/dist/modules/sales/commands/documents.js.map +2 -2
  56. package/dist/modules/sales/components/documents/ItemsSection.js +75 -21
  57. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  58. package/dist/modules/sales/components/documents/lineItemUtils.js +2 -2
  59. package/dist/modules/sales/components/documents/lineItemUtils.js.map +2 -2
  60. package/dist/modules/staff/api/timesheets/access-requests/route.js +11 -5
  61. package/dist/modules/staff/api/timesheets/access-requests/route.js.map +2 -2
  62. package/dist/modules/staff/api/timesheets/time-projects/route.js +19 -12
  63. package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
  64. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js +21 -12
  65. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js.map +2 -2
  66. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +14 -9
  67. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  68. package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js +31 -10
  69. package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js.map +2 -2
  70. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js +64 -0
  71. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js.map +7 -0
  72. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js +13 -0
  73. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js.map +7 -0
  74. package/dist/modules/sync_excel/index.js +11 -1
  75. package/dist/modules/sync_excel/index.js.map +2 -2
  76. package/dist/modules/translations/di.js +2 -0
  77. package/dist/modules/translations/di.js.map +2 -2
  78. package/package.json +7 -7
  79. package/src/modules/cart/api/current/lines/bulk/route.ts +1 -1
  80. package/src/modules/cart/api/current/lines/route.ts +1 -1
  81. package/src/modules/cart/commands/lines.ts +5 -10
  82. package/src/modules/cart/i18n/de.json +1 -0
  83. package/src/modules/cart/i18n/en.json +1 -0
  84. package/src/modules/cart/i18n/es.json +1 -0
  85. package/src/modules/cart/i18n/ko.json +1 -0
  86. package/src/modules/cart/i18n/pl.json +1 -0
  87. package/src/modules/cart/lib/currentCartRoute.ts +8 -0
  88. package/src/modules/cart/lib/lineMutationLock.ts +59 -0
  89. package/src/modules/customer_accounts/api/admin/roles.ts +26 -2
  90. package/src/modules/customer_accounts/api/admin/users.ts +26 -7
  91. package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +96 -13
  92. package/src/modules/customer_accounts/backend/customer_accounts/users/page.tsx +9 -1
  93. package/src/modules/customer_accounts/data/validators.ts +1 -0
  94. package/src/modules/customer_accounts/i18n/de.json +10 -0
  95. package/src/modules/customer_accounts/i18n/en.json +10 -0
  96. package/src/modules/customer_accounts/i18n/es.json +10 -0
  97. package/src/modules/customer_accounts/i18n/ko.json +10 -0
  98. package/src/modules/customer_accounts/i18n/pl.json +10 -0
  99. package/src/modules/customer_accounts/lib/adminOrganizationScope.ts +71 -0
  100. package/src/modules/customers/api/customer-dictionary-entries/route.ts +82 -0
  101. package/src/modules/customers/api/dictionaries/[kind]/route.ts +29 -6
  102. package/src/modules/customers/cli.ts +23 -124
  103. package/src/modules/customers/components/DictionarySettings.tsx +1 -1
  104. package/src/modules/customers/components/detail/ManageTagsDialog.tsx +1 -1
  105. package/src/modules/customers/components/detail/TaskDialog.tsx +15 -1
  106. package/src/modules/customers/components/detail/TaskForm.tsx +1 -17
  107. package/src/modules/customers/components/detail/TasksSection.tsx +44 -14
  108. package/src/modules/customers/components/detail/hooks/useCustomerDictionary.ts +14 -8
  109. package/src/modules/customers/components/detail/hooks/useInteractions.ts +3 -4
  110. package/src/modules/customers/i18n/de.json +81 -0
  111. package/src/modules/customers/i18n/en.json +81 -0
  112. package/src/modules/customers/i18n/es.json +81 -0
  113. package/src/modules/customers/i18n/ko.json +81 -0
  114. package/src/modules/customers/i18n/pl.json +81 -0
  115. package/src/modules/customers/lib/dictionaryDefaults.ts +137 -0
  116. package/src/modules/customers/lib/dictionaryLabels.ts +73 -0
  117. package/src/modules/customers/lib/interactionCompatibility.ts +1 -0
  118. package/src/modules/customers/translations.ts +5 -0
  119. package/src/modules/eudr/index.ts +13 -0
  120. package/src/modules/sales/commands/documents.ts +3 -3
  121. package/src/modules/sales/components/documents/ItemsSection.tsx +82 -23
  122. package/src/modules/sales/components/documents/lineItemUtils.ts +2 -2
  123. package/src/modules/sales/i18n/de.json +1 -0
  124. package/src/modules/sales/i18n/en.json +1 -0
  125. package/src/modules/sales/i18n/es.json +1 -0
  126. package/src/modules/sales/i18n/ko.json +1 -0
  127. package/src/modules/sales/i18n/pl.json +1 -0
  128. package/src/modules/staff/api/timesheets/access-requests/route.ts +13 -14
  129. package/src/modules/staff/api/timesheets/time-projects/route.ts +22 -13
  130. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.tsx +25 -14
  131. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +14 -13
  132. package/src/modules/staff/lib/time-tracking-ui/NoProjectAccess.tsx +30 -8
  133. package/src/modules/staff/lib/time-tracking-ui/projectAccessScope.ts +102 -0
  134. package/src/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.ts +15 -0
  135. package/src/modules/sync_excel/index.ts +10 -0
  136. package/src/modules/translations/di.ts +2 -0
@@ -15,6 +15,8 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
15
15
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
16
16
  import { DEFAULT_BUDGET_WARN_AT_PERCENT, type ProjectBudgetKind } from '../../../../../../lib/time-tracking-ui/ProjectFormSections'
17
17
  import { NoProjectAccess } from '../../../../../../lib/time-tracking-ui/NoProjectAccess'
18
+ import { useProjectAccessScope } from '../../../../../../lib/time-tracking-ui/projectAccessScope'
19
+ import { readResolvedProjectAccessScope, type ResolvedProjectAccessScope } from '../../../../../../lib/time-tracking-ui/projectAccessScopeHeaders'
18
20
  import {
19
21
  buildProjectPayload,
20
22
  createProjectFormFields,
@@ -36,7 +38,7 @@ const NO_PROJECT_ACCESS_REASON = 'no_project_access'
36
38
 
37
39
  /** Thrown by the loader so the caller can branch on the guard state. */
38
40
  class ProjectAccessDeniedError extends Error {
39
- constructor() {
41
+ constructor(readonly resolvedScope: ResolvedProjectAccessScope | null) {
40
42
  super('[internal] no project access')
41
43
  this.name = 'ProjectAccessDeniedError'
42
44
  }
@@ -106,25 +108,28 @@ export default function TimeTrackingProjectEditPage({ params }: { params?: { id?
106
108
  const t = useT()
107
109
  const router = useRouter()
108
110
  const scopeVersion = useOrganizationScopeVersion()
111
+ const { scopeKey: projectAccessScopeKey, resolvedScope, resolveScope } = useProjectAccessScope(projectId)
109
112
 
110
113
  const [initialValues, setInitialValues] = React.useState<ProjectFormValues | null>(null)
111
114
  const [loading, setLoading] = React.useState(true)
112
115
  const [error, setError] = React.useState<string | null>(null)
113
116
  const [isNotFound, setIsNotFound] = React.useState(false)
114
- const [accessDenied, setAccessDenied] = React.useState(false)
117
+ const [accessDeniedScopeKey, setAccessDeniedScopeKey] = React.useState<string | null>(null)
118
+ const [loadedScopeKey, setLoadedScopeKey] = React.useState<string | null>(null)
119
+ const accessDenied = accessDeniedScopeKey === projectAccessScopeKey
115
120
 
116
- const fetchProject = React.useCallback(async (): Promise<ProjectRecord | null> => {
121
+ const fetchProject = React.useCallback(async (): Promise<{ record: ProjectRecord | null; scope: ResolvedProjectAccessScope | null }> => {
117
122
  const call = await apiCall<{ items?: ProjectRecord[]; reason?: unknown }>(
118
123
  `/api/staff/timesheets/time-projects?ids=${projectId}&pageSize=1`,
119
124
  )
120
125
  if (call.status === 404 && call.result?.reason === NO_PROJECT_ACCESS_REASON) {
121
- throw new ProjectAccessDeniedError()
126
+ throw new ProjectAccessDeniedError(readResolvedProjectAccessScope(call.response))
122
127
  }
123
128
  if (!call.ok) {
124
129
  throw new Error(t('staff.timesheets.projects.errors.load', 'Failed to load project.'))
125
130
  }
126
131
  const record = Array.isArray(call.result?.items) ? call.result.items[0] : null
127
- return record ?? null
132
+ return { record: record ?? null, scope: readResolvedProjectAccessScope(call.response) }
128
133
  }, [projectId, t])
129
134
 
130
135
  /**
@@ -137,7 +142,7 @@ export default function TimeTrackingProjectEditPage({ params }: { params?: { id?
137
142
  const handleCurrencyChanged = React.useCallback(() => {
138
143
  if (!projectId) return
139
144
  void fetchProject()
140
- .then((record) => { if (record) setInitialValues(toValues(record)) })
145
+ .then(({ record }) => { if (record) setInitialValues(toValues(record)) })
141
146
  .catch(() => {
142
147
  // Best effort: the optimistic-lock conflict bar remains the backstop.
143
148
  })
@@ -159,25 +164,30 @@ export default function TimeTrackingProjectEditPage({ params }: { params?: { id?
159
164
  // it was loaded for. The decision looks at the load this one replaces,
160
165
  // settled or not: a version-0 load cancelled by a quick second switch must
161
166
  // not let that switch through.
162
- const lastLoadRef = React.useRef<{ projectId: string; scopeVersion: number } | null>(null)
167
+ const lastLoadRef = React.useRef<{ projectId: string; scopeVersion: number; scopeKey: string } | null>(null)
163
168
  React.useEffect(() => {
164
169
  if (!projectId) return
165
170
  let cancelled = false
166
171
  const replaced = lastLoadRef.current
167
- lastLoadRef.current = { projectId, scopeVersion }
168
- const keepScreen = replaced !== null && replaced.projectId === projectId && replaced.scopeVersion === 0
172
+ lastLoadRef.current = { projectId, scopeVersion, scopeKey: projectAccessScopeKey }
173
+ const keepScreen = replaced !== null
174
+ && replaced.projectId === projectId
175
+ && replaced.scopeVersion === 0
176
+ && replaced.scopeKey === projectAccessScopeKey
169
177
  async function load() {
170
178
  if (!keepScreen) setLoading(true)
171
179
  const settle = (outcome: { values?: ProjectFormValues; accessDenied?: boolean; isNotFound?: boolean; error?: string }) => {
172
180
  if (cancelled) return
173
- setAccessDenied(outcome.accessDenied === true)
181
+ setAccessDeniedScopeKey(outcome.accessDenied === true ? projectAccessScopeKey : null)
174
182
  setIsNotFound(outcome.isNotFound === true)
175
183
  setError(outcome.error ?? null)
176
184
  if (outcome.values) setInitialValues(outcome.values)
185
+ setLoadedScopeKey(projectAccessScopeKey)
177
186
  setLoading(false)
178
187
  }
179
188
  try {
180
- const record = await fetchProject()
189
+ const { record, scope } = await fetchProject()
190
+ if (!cancelled) resolveScope(scope)
181
191
  if (!record) {
182
192
  settle({ isNotFound: true })
183
193
  return
@@ -185,6 +195,7 @@ export default function TimeTrackingProjectEditPage({ params }: { params?: { id?
185
195
  settle({ values: toValues(record) })
186
196
  } catch (err) {
187
197
  if (err instanceof ProjectAccessDeniedError) {
198
+ if (!cancelled) resolveScope(err.resolvedScope)
188
199
  settle({ accessDenied: true })
189
200
  return
190
201
  }
@@ -197,16 +208,16 @@ export default function TimeTrackingProjectEditPage({ params }: { params?: { id?
197
208
  }
198
209
  load()
199
210
  return () => { cancelled = true }
200
- }, [projectId, fetchProject, scopeVersion, t])
211
+ }, [projectId, fetchProject, scopeVersion, t, projectAccessScopeKey, resolveScope])
201
212
 
202
- if (loading) {
213
+ if (!accessDenied && (loading || loadedScopeKey !== projectAccessScopeKey)) {
203
214
  return <Page><PageBody><LoadingMessage label={t('staff.timesheets.projects.loading', 'Loading project...')} /></PageBody></Page>
204
215
  }
205
216
 
206
217
  // Screen 17 — the caller may not open this project. Same guard state the
207
218
  // detail page renders; it names neither the customer nor the project.
208
219
  if (accessDenied) {
209
- return <NoProjectAccess timeProjectId={projectId} />
220
+ return <NoProjectAccess timeProjectId={projectId} resolvedScope={resolvedScope} />
210
221
  }
211
222
 
212
223
  if (isNotFound) {
@@ -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)