@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/cart/api/current/lines/bulk/route.js +1 -1
- package/dist/modules/cart/api/current/lines/bulk/route.js.map +2 -2
- package/dist/modules/cart/api/current/lines/route.js +1 -1
- package/dist/modules/cart/api/current/lines/route.js.map +2 -2
- package/dist/modules/cart/commands/lines.js +5 -10
- package/dist/modules/cart/commands/lines.js.map +2 -2
- package/dist/modules/cart/lib/currentCartRoute.js +8 -0
- package/dist/modules/cart/lib/currentCartRoute.js.map +3 -3
- package/dist/modules/cart/lib/lineMutationLock.js +52 -0
- package/dist/modules/cart/lib/lineMutationLock.js.map +7 -0
- package/dist/modules/customer_accounts/api/admin/roles.js +25 -2
- package/dist/modules/customer_accounts/api/admin/roles.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/users.js +24 -7
- package/dist/modules/customer_accounts/api/admin/users.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +77 -15
- package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js +12 -1
- package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js.map +2 -2
- package/dist/modules/customer_accounts/data/validators.js +2 -1
- package/dist/modules/customer_accounts/data/validators.js.map +2 -2
- package/dist/modules/customer_accounts/lib/adminOrganizationScope.js +46 -0
- package/dist/modules/customer_accounts/lib/adminOrganizationScope.js.map +7 -0
- package/dist/modules/customers/api/customer-dictionary-entries/route.js +86 -0
- package/dist/modules/customers/api/customer-dictionary-entries/route.js.map +7 -0
- package/dist/modules/customers/api/dictionaries/[kind]/route.js +29 -7
- package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
- package/dist/modules/customers/cli.js +28 -110
- package/dist/modules/customers/cli.js.map +3 -3
- package/dist/modules/customers/components/DictionarySettings.js +1 -1
- package/dist/modules/customers/components/DictionarySettings.js.map +1 -1
- package/dist/modules/customers/components/detail/ManageTagsDialog.js +1 -1
- package/dist/modules/customers/components/detail/ManageTagsDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/TaskDialog.js +14 -1
- package/dist/modules/customers/components/detail/TaskDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/TaskForm.js +1 -16
- package/dist/modules/customers/components/detail/TaskForm.js.map +2 -2
- package/dist/modules/customers/components/detail/TasksSection.js +38 -10
- package/dist/modules/customers/components/detail/TasksSection.js.map +2 -2
- package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js +12 -5
- package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js.map +2 -2
- package/dist/modules/customers/components/detail/hooks/useInteractions.js +3 -13
- package/dist/modules/customers/components/detail/hooks/useInteractions.js.map +2 -2
- package/dist/modules/customers/lib/dictionaryDefaults.js +134 -0
- package/dist/modules/customers/lib/dictionaryDefaults.js.map +7 -0
- package/dist/modules/customers/lib/dictionaryLabels.js +59 -0
- package/dist/modules/customers/lib/dictionaryLabels.js.map +7 -0
- package/dist/modules/customers/lib/interactionCompatibility.js +1 -0
- package/dist/modules/customers/lib/interactionCompatibility.js.map +2 -2
- package/dist/modules/customers/translations.js +9 -0
- package/dist/modules/customers/translations.js.map +7 -0
- package/dist/modules/eudr/index.js +13 -0
- package/dist/modules/eudr/index.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +3 -3
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/components/documents/ItemsSection.js +75 -21
- package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/lineItemUtils.js +2 -2
- package/dist/modules/sales/components/documents/lineItemUtils.js.map +2 -2
- package/dist/modules/staff/api/timesheets/access-requests/route.js +11 -5
- package/dist/modules/staff/api/timesheets/access-requests/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-projects/route.js +19 -12
- package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js +21 -12
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js.map +2 -2
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +14 -9
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
- package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js +31 -10
- package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js.map +2 -2
- package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js +64 -0
- package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js +13 -0
- package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js.map +7 -0
- package/dist/modules/sync_excel/index.js +11 -1
- package/dist/modules/sync_excel/index.js.map +2 -2
- package/dist/modules/translations/di.js +2 -0
- package/dist/modules/translations/di.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/cart/api/current/lines/bulk/route.ts +1 -1
- package/src/modules/cart/api/current/lines/route.ts +1 -1
- package/src/modules/cart/commands/lines.ts +5 -10
- package/src/modules/cart/i18n/de.json +1 -0
- package/src/modules/cart/i18n/en.json +1 -0
- package/src/modules/cart/i18n/es.json +1 -0
- package/src/modules/cart/i18n/ko.json +1 -0
- package/src/modules/cart/i18n/pl.json +1 -0
- package/src/modules/cart/lib/currentCartRoute.ts +8 -0
- package/src/modules/cart/lib/lineMutationLock.ts +59 -0
- package/src/modules/customer_accounts/api/admin/roles.ts +26 -2
- package/src/modules/customer_accounts/api/admin/users.ts +26 -7
- package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +96 -13
- package/src/modules/customer_accounts/backend/customer_accounts/users/page.tsx +9 -1
- package/src/modules/customer_accounts/data/validators.ts +1 -0
- package/src/modules/customer_accounts/i18n/de.json +10 -0
- package/src/modules/customer_accounts/i18n/en.json +10 -0
- package/src/modules/customer_accounts/i18n/es.json +10 -0
- package/src/modules/customer_accounts/i18n/ko.json +10 -0
- package/src/modules/customer_accounts/i18n/pl.json +10 -0
- package/src/modules/customer_accounts/lib/adminOrganizationScope.ts +71 -0
- package/src/modules/customers/api/customer-dictionary-entries/route.ts +82 -0
- package/src/modules/customers/api/dictionaries/[kind]/route.ts +29 -6
- package/src/modules/customers/cli.ts +23 -124
- package/src/modules/customers/components/DictionarySettings.tsx +1 -1
- package/src/modules/customers/components/detail/ManageTagsDialog.tsx +1 -1
- package/src/modules/customers/components/detail/TaskDialog.tsx +15 -1
- package/src/modules/customers/components/detail/TaskForm.tsx +1 -17
- package/src/modules/customers/components/detail/TasksSection.tsx +44 -14
- package/src/modules/customers/components/detail/hooks/useCustomerDictionary.ts +14 -8
- package/src/modules/customers/components/detail/hooks/useInteractions.ts +3 -4
- package/src/modules/customers/i18n/de.json +81 -0
- package/src/modules/customers/i18n/en.json +81 -0
- package/src/modules/customers/i18n/es.json +81 -0
- package/src/modules/customers/i18n/ko.json +81 -0
- package/src/modules/customers/i18n/pl.json +81 -0
- package/src/modules/customers/lib/dictionaryDefaults.ts +137 -0
- package/src/modules/customers/lib/dictionaryLabels.ts +73 -0
- package/src/modules/customers/lib/interactionCompatibility.ts +1 -0
- package/src/modules/customers/translations.ts +5 -0
- package/src/modules/eudr/index.ts +13 -0
- package/src/modules/sales/commands/documents.ts +3 -3
- package/src/modules/sales/components/documents/ItemsSection.tsx +82 -23
- package/src/modules/sales/components/documents/lineItemUtils.ts +2 -2
- package/src/modules/sales/i18n/de.json +1 -0
- package/src/modules/sales/i18n/en.json +1 -0
- package/src/modules/sales/i18n/es.json +1 -0
- package/src/modules/sales/i18n/ko.json +1 -0
- package/src/modules/sales/i18n/pl.json +1 -0
- package/src/modules/staff/api/timesheets/access-requests/route.ts +13 -14
- package/src/modules/staff/api/timesheets/time-projects/route.ts +22 -13
- package/src/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.tsx +25 -14
- package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +14 -13
- package/src/modules/staff/lib/time-tracking-ui/NoProjectAccess.tsx +30 -8
- package/src/modules/staff/lib/time-tracking-ui/projectAccessScope.ts +102 -0
- package/src/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.ts +15 -0
- package/src/modules/sync_excel/index.ts +10 -0
- 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 [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
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)
|
|
413
|
+
if (!cancelled) setAccessDeniedScopeKey(projectAccessScopeKey)
|
|
414
414
|
return
|
|
415
415
|
}
|
|
416
|
-
if (!cancelled)
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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)
|