@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/api_keys/api/keys/route.js +15 -1
- package/dist/modules/api_keys/api/keys/route.js.map +2 -2
- package/dist/modules/auth/api/users/route.js +16 -1
- package/dist/modules/auth/api/users/route.js.map +2 -2
- package/dist/modules/auth/backend/users/page.js +13 -2
- package/dist/modules/auth/backend/users/page.js.map +2 -2
- package/dist/modules/catalog/commands/products.js +4 -2
- package/dist/modules/catalog/commands/products.js.map +2 -2
- 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/directory/api/organization-branding/route.js +6 -9
- package/dist/modules/directory/api/organization-branding/route.js.map +2 -2
- package/dist/modules/directory/backend/directory/branding/page.js +15 -3
- package/dist/modules/directory/backend/directory/branding/page.js.map +2 -2
- package/dist/modules/eudr/index.js +13 -0
- package/dist/modules/eudr/index.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/dist/modules/wms/api/listEnrichers.js +3 -1
- package/dist/modules/wms/api/listEnrichers.js.map +2 -2
- package/dist/modules/wms/index.js +1 -1
- package/dist/modules/wms/index.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/api_keys/api/keys/route.ts +15 -1
- package/src/modules/api_keys/i18n/de.json +1 -0
- package/src/modules/api_keys/i18n/en.json +1 -0
- package/src/modules/api_keys/i18n/es.json +1 -0
- package/src/modules/api_keys/i18n/ko.json +1 -0
- package/src/modules/api_keys/i18n/pl.json +1 -0
- package/src/modules/auth/api/users/route.ts +18 -1
- package/src/modules/auth/backend/users/page.tsx +14 -2
- package/src/modules/auth/i18n/de.json +1 -0
- package/src/modules/auth/i18n/en.json +1 -0
- package/src/modules/auth/i18n/es.json +1 -0
- package/src/modules/auth/i18n/ko.json +1 -0
- package/src/modules/auth/i18n/pl.json +1 -0
- package/src/modules/catalog/commands/products.ts +4 -1
- 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/directory/api/organization-branding/route.ts +5 -8
- package/src/modules/directory/backend/directory/branding/page.tsx +15 -3
- package/src/modules/eudr/index.ts +13 -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
- package/src/modules/wms/api/listEnrichers.ts +5 -2
- package/src/modules/wms/index.ts +1 -1
|
@@ -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 } from "../../../../../../lib/time-tracking-ui/ProjectFormSections.js";
|
|
17
17
|
import { NoProjectAccess } from "../../../../../../lib/time-tracking-ui/NoProjectAccess.js";
|
|
18
|
+
import { useProjectAccessScope } from "../../../../../../lib/time-tracking-ui/projectAccessScope.js";
|
|
19
|
+
import { readResolvedProjectAccessScope } from "../../../../../../lib/time-tracking-ui/projectAccessScopeHeaders.js";
|
|
18
20
|
import {
|
|
19
21
|
buildProjectPayload,
|
|
20
22
|
createProjectFormFields,
|
|
@@ -25,8 +27,9 @@ import {
|
|
|
25
27
|
const LIST_HREF = "/backend/staff/time-tracking/projects";
|
|
26
28
|
const NO_PROJECT_ACCESS_REASON = "no_project_access";
|
|
27
29
|
class ProjectAccessDeniedError extends Error {
|
|
28
|
-
constructor() {
|
|
30
|
+
constructor(resolvedScope) {
|
|
29
31
|
super("[internal] no project access");
|
|
32
|
+
this.resolvedScope = resolvedScope;
|
|
30
33
|
this.name = "ProjectAccessDeniedError";
|
|
31
34
|
}
|
|
32
35
|
}
|
|
@@ -86,27 +89,30 @@ function TimeTrackingProjectEditPage({ params }) {
|
|
|
86
89
|
const t = useT();
|
|
87
90
|
const router = useRouter();
|
|
88
91
|
const scopeVersion = useOrganizationScopeVersion();
|
|
92
|
+
const { scopeKey: projectAccessScopeKey, resolvedScope, resolveScope } = useProjectAccessScope(projectId);
|
|
89
93
|
const [initialValues, setInitialValues] = React.useState(null);
|
|
90
94
|
const [loading, setLoading] = React.useState(true);
|
|
91
95
|
const [error, setError] = React.useState(null);
|
|
92
96
|
const [isNotFound, setIsNotFound] = React.useState(false);
|
|
93
|
-
const [
|
|
97
|
+
const [accessDeniedScopeKey, setAccessDeniedScopeKey] = React.useState(null);
|
|
98
|
+
const [loadedScopeKey, setLoadedScopeKey] = React.useState(null);
|
|
99
|
+
const accessDenied = accessDeniedScopeKey === projectAccessScopeKey;
|
|
94
100
|
const fetchProject = React.useCallback(async () => {
|
|
95
101
|
const call = await apiCall(
|
|
96
102
|
`/api/staff/timesheets/time-projects?ids=${projectId}&pageSize=1`
|
|
97
103
|
);
|
|
98
104
|
if (call.status === 404 && call.result?.reason === NO_PROJECT_ACCESS_REASON) {
|
|
99
|
-
throw new ProjectAccessDeniedError();
|
|
105
|
+
throw new ProjectAccessDeniedError(readResolvedProjectAccessScope(call.response));
|
|
100
106
|
}
|
|
101
107
|
if (!call.ok) {
|
|
102
108
|
throw new Error(t("staff.timesheets.projects.errors.load", "Failed to load project."));
|
|
103
109
|
}
|
|
104
110
|
const record = Array.isArray(call.result?.items) ? call.result.items[0] : null;
|
|
105
|
-
return record ?? null;
|
|
111
|
+
return { record: record ?? null, scope: readResolvedProjectAccessScope(call.response) };
|
|
106
112
|
}, [projectId, t]);
|
|
107
113
|
const handleCurrencyChanged = React.useCallback(() => {
|
|
108
114
|
if (!projectId) return;
|
|
109
|
-
void fetchProject().then((record) => {
|
|
115
|
+
void fetchProject().then(({ record }) => {
|
|
110
116
|
if (record) setInitialValues(toValues(record));
|
|
111
117
|
}).catch(() => {
|
|
112
118
|
});
|
|
@@ -122,20 +128,22 @@ function TimeTrackingProjectEditPage({ params }) {
|
|
|
122
128
|
if (!projectId) return;
|
|
123
129
|
let cancelled = false;
|
|
124
130
|
const replaced = lastLoadRef.current;
|
|
125
|
-
lastLoadRef.current = { projectId, scopeVersion };
|
|
126
|
-
const keepScreen = replaced !== null && replaced.projectId === projectId && replaced.scopeVersion === 0;
|
|
131
|
+
lastLoadRef.current = { projectId, scopeVersion, scopeKey: projectAccessScopeKey };
|
|
132
|
+
const keepScreen = replaced !== null && replaced.projectId === projectId && replaced.scopeVersion === 0 && replaced.scopeKey === projectAccessScopeKey;
|
|
127
133
|
async function load() {
|
|
128
134
|
if (!keepScreen) setLoading(true);
|
|
129
135
|
const settle = (outcome) => {
|
|
130
136
|
if (cancelled) return;
|
|
131
|
-
|
|
137
|
+
setAccessDeniedScopeKey(outcome.accessDenied === true ? projectAccessScopeKey : null);
|
|
132
138
|
setIsNotFound(outcome.isNotFound === true);
|
|
133
139
|
setError(outcome.error ?? null);
|
|
134
140
|
if (outcome.values) setInitialValues(outcome.values);
|
|
141
|
+
setLoadedScopeKey(projectAccessScopeKey);
|
|
135
142
|
setLoading(false);
|
|
136
143
|
};
|
|
137
144
|
try {
|
|
138
|
-
const record = await fetchProject();
|
|
145
|
+
const { record, scope } = await fetchProject();
|
|
146
|
+
if (!cancelled) resolveScope(scope);
|
|
139
147
|
if (!record) {
|
|
140
148
|
settle({ isNotFound: true });
|
|
141
149
|
return;
|
|
@@ -143,6 +151,7 @@ function TimeTrackingProjectEditPage({ params }) {
|
|
|
143
151
|
settle({ values: toValues(record) });
|
|
144
152
|
} catch (err) {
|
|
145
153
|
if (err instanceof ProjectAccessDeniedError) {
|
|
154
|
+
if (!cancelled) resolveScope(err.resolvedScope);
|
|
146
155
|
settle({ accessDenied: true });
|
|
147
156
|
return;
|
|
148
157
|
}
|
|
@@ -155,12 +164,12 @@ function TimeTrackingProjectEditPage({ params }) {
|
|
|
155
164
|
return () => {
|
|
156
165
|
cancelled = true;
|
|
157
166
|
};
|
|
158
|
-
}, [projectId, fetchProject, scopeVersion, t]);
|
|
159
|
-
if (loading) {
|
|
167
|
+
}, [projectId, fetchProject, scopeVersion, t, projectAccessScopeKey, resolveScope]);
|
|
168
|
+
if (!accessDenied && (loading || loadedScopeKey !== projectAccessScopeKey)) {
|
|
160
169
|
return /* @__PURE__ */ jsx(Page, { children: /* @__PURE__ */ jsx(PageBody, { children: /* @__PURE__ */ jsx(LoadingMessage, { label: t("staff.timesheets.projects.loading", "Loading project...") }) }) });
|
|
161
170
|
}
|
|
162
171
|
if (accessDenied) {
|
|
163
|
-
return /* @__PURE__ */ jsx(NoProjectAccess, { timeProjectId: projectId });
|
|
172
|
+
return /* @__PURE__ */ jsx(NoProjectAccess, { timeProjectId: projectId, resolvedScope });
|
|
164
173
|
}
|
|
165
174
|
if (isNotFound) {
|
|
166
175
|
return /* @__PURE__ */ jsx(Page, { children: /* @__PURE__ */ jsx(PageBody, { children: /* @__PURE__ */ jsx(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../../../src/modules/staff/backend/staff/time-tracking/projects/%5Bid%5D/edit/page.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport { extensionPoints } from '@open-mercato/core/modules/staff/extension-points'\nimport * as React from 'react'\nimport { useRouter } from 'next/navigation'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { CrudForm } from '@open-mercato/ui/backend/CrudForm'\nimport { updateCrud, deleteCrud } from '@open-mercato/ui/backend/utils/crud'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'\nimport { E } from '#generated/entities.ids.generated'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { LoadingMessage, ErrorMessage, RecordNotFoundState } from '@open-mercato/ui/backend/detail'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { DEFAULT_BUDGET_WARN_AT_PERCENT, type ProjectBudgetKind } from '../../../../../../lib/time-tracking-ui/ProjectFormSections'\nimport { NoProjectAccess } from '../../../../../../lib/time-tracking-ui/NoProjectAccess'\nimport {\n buildProjectPayload,\n createProjectFormFields,\n createProjectFormGroups,\n createProjectFormSchema,\n pickCustomFieldValues,\n type ProjectFormValues,\n} from '../../projectFormConfig'\n\nconst LIST_HREF = '/backend/staff/time-tracking/projects'\n\n/**\n * Mirrors `NO_PROJECT_ACCESS_REASON` from\n * `api/timesheets/time-projects/route.ts`. The route module is server-only, so\n * the literal is restated here rather than imported into the client bundle;\n * `__tests__/no-project-access-contract.test.ts` pins the two together.\n */\nconst NO_PROJECT_ACCESS_REASON = 'no_project_access'\n\n/** Thrown by the loader so the caller can branch on the guard state. */\nclass ProjectAccessDeniedError extends Error {\n constructor() {\n super('[internal] no project access')\n this.name = 'ProjectAccessDeniedError'\n }\n}\n\ntype ProjectRecord = Record<string, unknown>\n\nfunction readString(record: ProjectRecord, ...keys: string[]): string | null {\n for (const key of keys) {\n const value = record[key]\n if (typeof value === 'string' && value.length > 0) return value\n if (typeof value === 'number') return String(value)\n }\n return null\n}\n\nfunction readSnapshot(record: ProjectRecord): Record<string, unknown> | null {\n const raw = record.customerSnapshot ?? record.customer_snapshot\n if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw as Record<string, unknown>\n return null\n}\n\nfunction toValues(record: ProjectRecord): ProjectFormValues {\n const snapshot = readSnapshot(record)\n const budgetKindRaw = readString(record, 'budgetKind', 'budget_kind') ?? 'none'\n const budgetKind: ProjectBudgetKind =\n budgetKindRaw === 'hours' || budgetKindRaw === 'amount' ? budgetKindRaw : 'none'\n const entryCount = typeof record.entryCount === 'number' ? record.entryCount : 0\n\n return {\n id: String(record.id ?? ''),\n updatedAt: readString(record, 'updatedAt', 'updated_at'),\n name: String(record.name ?? ''),\n code: String(record.code ?? ''),\n // D-10: an existing project keeps the code it was saved with \u2014 the name no\n // longer drives it.\n codeManual: true,\n customerId: readString(record, 'customerId', 'customer_id'),\n customerName: typeof snapshot?.name === 'string' ? snapshot.name : null,\n customerKind: typeof snapshot?.kind === 'string' ? snapshot.kind : null,\n customerTaxId: typeof snapshot?.taxId === 'string' ? snapshot.taxId : null,\n description: readString(record, 'description'),\n hourlyRate: readString(record, 'hourlyRate', 'hourly_rate'),\n currencyCode: readString(record, 'currencyCode', 'currency_code'),\n currencyLocked: record.currencyLocked === true,\n entryCount,\n billableByDefault: (record.billableByDefault ?? record.billable_by_default) !== false,\n budgetEnabled: budgetKind !== 'none',\n budgetKind: budgetKind === 'none' ? 'hours' : budgetKind,\n budgetValue: readString(record, 'budgetValue', 'budget_value'),\n budgetWarnAtPercent:\n readString(record, 'budgetWarnAtPercent', 'budget_warn_at_percent') ?? String(DEFAULT_BUDGET_WARN_AT_PERCENT),\n defaultAssigneeStaffMemberId: readString(record, 'defaultAssigneeStaffMemberId', 'default_assignee_staff_member_id') ?? null,\n projectType: readString(record, 'projectType', 'project_type'),\n color: readString(record, 'color'),\n startDate: readString(record, 'startDate', 'start_date'),\n costCenter: readString(record, 'costCenter', 'cost_center'),\n status: readString(record, 'status') ?? 'active',\n // `decorateCustomFields` on the list route returns the saved values as\n // `cf_<key>`, which is exactly what `CrudForm` looks an initial value up by.\n ...pickCustomFieldValues(record as Record<string, unknown>),\n }\n}\n\nexport default function TimeTrackingProjectEditPage({ params }: { params?: { id?: string } }) {\n const projectId = params?.id\n const t = useT()\n const router = useRouter()\n const scopeVersion = useOrganizationScopeVersion()\n\n const [initialValues, setInitialValues] = React.useState<ProjectFormValues | null>(null)\n const [loading, setLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n const [isNotFound, setIsNotFound] = React.useState(false)\n const [accessDenied, setAccessDenied] = React.useState(false)\n\n const fetchProject = React.useCallback(async (): Promise<ProjectRecord | null> => {\n const call = await apiCall<{ items?: ProjectRecord[]; reason?: unknown }>(\n `/api/staff/timesheets/time-projects?ids=${projectId}&pageSize=1`,\n )\n if (call.status === 404 && call.result?.reason === NO_PROJECT_ACCESS_REASON) {\n throw new ProjectAccessDeniedError()\n }\n if (!call.ok) {\n throw new Error(t('staff.timesheets.projects.errors.load', 'Failed to load project.'))\n }\n const record = Array.isArray(call.result?.items) ? call.result.items[0] : null\n return record ?? null\n }, [projectId, t])\n\n /**\n * D-3: the change-currency action writes to the project directly, so it bumped\n * both the currency and `updated_at`. Reload the record \u2014 the form derives its\n * optimistic-lock version from `initialValues.updatedAt`, and a stale one would\n * fail the next save with a conflict this same user had just caused. `CrudForm`\n * merges refreshed initial values without discarding fields being edited.\n */\n const handleCurrencyChanged = React.useCallback(() => {\n if (!projectId) return\n void fetchProject()\n .then((record) => { if (record) setInitialValues(toValues(record)) })\n .catch(() => {\n // Best effort: the optimistic-lock conflict bar remains the backstop.\n })\n }, [fetchProject, projectId])\n\n const formSchema = React.useMemo(() => createProjectFormSchema(), [])\n const fields = React.useMemo(\n () => createProjectFormFields(t, { onCurrencyChanged: handleCurrencyChanged }),\n [t, handleCurrencyChanged],\n )\n const groups = React.useMemo(() => createProjectFormGroups(t), [t])\n\n // The organization switcher settles the scope just after mount, bumping the\n // scope version from 0 and reloading the project. A screen resolved before\n // that stays up until the answer arrives: falling back to the full-page\n // loader would unmount it and drop its local state \u2014 the form's unsaved\n // edits, or an access request the user has just sent. A switch between two\n // known scopes still goes through the loader, so no form outlives the scope\n // it was loaded for. The decision looks at the load this one replaces,\n // settled or not: a version-0 load cancelled by a quick second switch must\n // not let that switch through.\n const lastLoadRef = React.useRef<{ projectId: string; scopeVersion: number } | null>(null)\n React.useEffect(() => {\n if (!projectId) return\n let cancelled = false\n const replaced = lastLoadRef.current\n lastLoadRef.current = { projectId, scopeVersion }\n const keepScreen = replaced !== null && replaced.projectId === projectId && replaced.scopeVersion === 0\n async function load() {\n if (!keepScreen) setLoading(true)\n const settle = (outcome: { values?: ProjectFormValues; accessDenied?: boolean; isNotFound?: boolean; error?: string }) => {\n if (cancelled) return\n setAccessDenied(outcome.accessDenied === true)\n setIsNotFound(outcome.isNotFound === true)\n setError(outcome.error ?? null)\n if (outcome.values) setInitialValues(outcome.values)\n setLoading(false)\n }\n try {\n const record = await fetchProject()\n if (!record) {\n settle({ isNotFound: true })\n return\n }\n settle({ values: toValues(record) })\n } catch (err) {\n if (err instanceof ProjectAccessDeniedError) {\n settle({ accessDenied: true })\n return\n }\n settle({\n error: err instanceof Error\n ? err.message\n : t('staff.timesheets.projects.errors.load', 'Failed to load project.'),\n })\n }\n }\n load()\n return () => { cancelled = true }\n }, [projectId, fetchProject, scopeVersion, t])\n\n if (loading) {\n return <Page><PageBody><LoadingMessage label={t('staff.timesheets.projects.loading', 'Loading project...')} /></PageBody></Page>\n }\n\n // Screen 17 \u2014 the caller may not open this project. Same guard state the\n // detail page renders; it names neither the customer nor the project.\n if (accessDenied) {\n return <NoProjectAccess timeProjectId={projectId} />\n }\n\n if (isNotFound) {\n return (\n <Page>\n <PageBody>\n <RecordNotFoundState\n label={t('staff.timesheets.projects.errors.notFound', 'Project not found.')}\n backHref={LIST_HREF}\n backLabel={t('staff.timesheets.projects.actions.backToList', 'Back to projects')}\n />\n </PageBody>\n </Page>\n )\n }\n\n if (error || !initialValues) {\n return <Page><PageBody><ErrorMessage label={error ?? t('staff.timesheets.projects.errors.load', 'Failed to load project.')} /></PageBody></Page>\n }\n\n const detailHref = `/backend/staff/time-tracking/projects/${projectId}`\n\n return (\n <Page>\n <PageBody>\n <CrudForm<ProjectFormValues>\n title={t('staff.time_tracking.projects.form.editTitle', 'Edit project')}\n titleHeadingLevel={1}\n backHref={detailHref}\n cancelHref={detailHref}\n fields={fields}\n groups={groups}\n schema={formSchema}\n initialValues={initialValues}\n entityIds={[E.staff.staff_time_project]}\n injectionSpotId={extensionPoints.hosts.projectForm.spotId}\n submitLabel={t('staff.timesheets.projects.form.actions.save', 'Save')}\n onDelete={async () => {\n await deleteCrud('staff/timesheets/time-projects', projectId!, {\n errorMessage: t('staff.timesheets.projects.errors.delete', 'Failed to delete project.'),\n })\n flash(t('staff.timesheets.projects.messages.deleted', 'Project deleted.'), 'success')\n router.push(LIST_HREF)\n }}\n onSubmit={async (values) => {\n if (!values.name?.trim() || !values.code?.trim() || !values.customerId) {\n const fieldErrors: Record<string, string> = {}\n if (!values.name?.trim()) {\n fieldErrors.name = t('staff.time_tracking.projects.validation.nameRequired', 'A project name is required.')\n }\n if (!values.code?.trim()) {\n fieldErrors.code = t('staff.time_tracking.projects.validation.codeRequired', 'A project code is required.')\n }\n if (!values.customerId) {\n fieldErrors.customerId = t('staff.time_tracking.projects.validation.customerRequired', 'A customer is required.')\n }\n throw createCrudFormError(\n t('staff.time_tracking.projects.errors.required', 'Fill in the required fields.'),\n fieldErrors,\n )\n }\n\n const payload = buildProjectPayload({ ...values, id: projectId })\n\n await updateCrud('staff/timesheets/time-projects', payload, {\n errorMessage: t('staff.timesheets.projects.errors.save', 'Failed to save project.'),\n })\n\n flash(t('staff.timesheets.projects.messages.saved', 'Project saved.'), 'success')\n router.push(detailHref)\n }}\n versionHistory={{\n resourceKind: 'staff.timesheets.time_project',\n resourceId: projectId!,\n canUndoRedo: true,\n autoCheckAcl: true,\n }}\n />\n </PageBody>\n </Page>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport { extensionPoints } from '@open-mercato/core/modules/staff/extension-points'\nimport * as React from 'react'\nimport { useRouter } from 'next/navigation'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { CrudForm } from '@open-mercato/ui/backend/CrudForm'\nimport { updateCrud, deleteCrud } from '@open-mercato/ui/backend/utils/crud'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'\nimport { E } from '#generated/entities.ids.generated'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { LoadingMessage, ErrorMessage, RecordNotFoundState } from '@open-mercato/ui/backend/detail'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { DEFAULT_BUDGET_WARN_AT_PERCENT, type ProjectBudgetKind } from '../../../../../../lib/time-tracking-ui/ProjectFormSections'\nimport { NoProjectAccess } from '../../../../../../lib/time-tracking-ui/NoProjectAccess'\nimport { useProjectAccessScope } from '../../../../../../lib/time-tracking-ui/projectAccessScope'\nimport { readResolvedProjectAccessScope, type ResolvedProjectAccessScope } from '../../../../../../lib/time-tracking-ui/projectAccessScopeHeaders'\nimport {\n buildProjectPayload,\n createProjectFormFields,\n createProjectFormGroups,\n createProjectFormSchema,\n pickCustomFieldValues,\n type ProjectFormValues,\n} from '../../projectFormConfig'\n\nconst LIST_HREF = '/backend/staff/time-tracking/projects'\n\n/**\n * Mirrors `NO_PROJECT_ACCESS_REASON` from\n * `api/timesheets/time-projects/route.ts`. The route module is server-only, so\n * the literal is restated here rather than imported into the client bundle;\n * `__tests__/no-project-access-contract.test.ts` pins the two together.\n */\nconst NO_PROJECT_ACCESS_REASON = 'no_project_access'\n\n/** Thrown by the loader so the caller can branch on the guard state. */\nclass ProjectAccessDeniedError extends Error {\n constructor(readonly resolvedScope: ResolvedProjectAccessScope | null) {\n super('[internal] no project access')\n this.name = 'ProjectAccessDeniedError'\n }\n}\n\ntype ProjectRecord = Record<string, unknown>\n\nfunction readString(record: ProjectRecord, ...keys: string[]): string | null {\n for (const key of keys) {\n const value = record[key]\n if (typeof value === 'string' && value.length > 0) return value\n if (typeof value === 'number') return String(value)\n }\n return null\n}\n\nfunction readSnapshot(record: ProjectRecord): Record<string, unknown> | null {\n const raw = record.customerSnapshot ?? record.customer_snapshot\n if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw as Record<string, unknown>\n return null\n}\n\nfunction toValues(record: ProjectRecord): ProjectFormValues {\n const snapshot = readSnapshot(record)\n const budgetKindRaw = readString(record, 'budgetKind', 'budget_kind') ?? 'none'\n const budgetKind: ProjectBudgetKind =\n budgetKindRaw === 'hours' || budgetKindRaw === 'amount' ? budgetKindRaw : 'none'\n const entryCount = typeof record.entryCount === 'number' ? record.entryCount : 0\n\n return {\n id: String(record.id ?? ''),\n updatedAt: readString(record, 'updatedAt', 'updated_at'),\n name: String(record.name ?? ''),\n code: String(record.code ?? ''),\n // D-10: an existing project keeps the code it was saved with \u2014 the name no\n // longer drives it.\n codeManual: true,\n customerId: readString(record, 'customerId', 'customer_id'),\n customerName: typeof snapshot?.name === 'string' ? snapshot.name : null,\n customerKind: typeof snapshot?.kind === 'string' ? snapshot.kind : null,\n customerTaxId: typeof snapshot?.taxId === 'string' ? snapshot.taxId : null,\n description: readString(record, 'description'),\n hourlyRate: readString(record, 'hourlyRate', 'hourly_rate'),\n currencyCode: readString(record, 'currencyCode', 'currency_code'),\n currencyLocked: record.currencyLocked === true,\n entryCount,\n billableByDefault: (record.billableByDefault ?? record.billable_by_default) !== false,\n budgetEnabled: budgetKind !== 'none',\n budgetKind: budgetKind === 'none' ? 'hours' : budgetKind,\n budgetValue: readString(record, 'budgetValue', 'budget_value'),\n budgetWarnAtPercent:\n readString(record, 'budgetWarnAtPercent', 'budget_warn_at_percent') ?? String(DEFAULT_BUDGET_WARN_AT_PERCENT),\n defaultAssigneeStaffMemberId: readString(record, 'defaultAssigneeStaffMemberId', 'default_assignee_staff_member_id') ?? null,\n projectType: readString(record, 'projectType', 'project_type'),\n color: readString(record, 'color'),\n startDate: readString(record, 'startDate', 'start_date'),\n costCenter: readString(record, 'costCenter', 'cost_center'),\n status: readString(record, 'status') ?? 'active',\n // `decorateCustomFields` on the list route returns the saved values as\n // `cf_<key>`, which is exactly what `CrudForm` looks an initial value up by.\n ...pickCustomFieldValues(record as Record<string, unknown>),\n }\n}\n\nexport default function TimeTrackingProjectEditPage({ params }: { params?: { id?: string } }) {\n const projectId = params?.id\n const t = useT()\n const router = useRouter()\n const scopeVersion = useOrganizationScopeVersion()\n const { scopeKey: projectAccessScopeKey, resolvedScope, resolveScope } = useProjectAccessScope(projectId)\n\n const [initialValues, setInitialValues] = React.useState<ProjectFormValues | null>(null)\n const [loading, setLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n const [isNotFound, setIsNotFound] = React.useState(false)\n const [accessDeniedScopeKey, setAccessDeniedScopeKey] = React.useState<string | null>(null)\n const [loadedScopeKey, setLoadedScopeKey] = React.useState<string | null>(null)\n const accessDenied = accessDeniedScopeKey === projectAccessScopeKey\n\n const fetchProject = React.useCallback(async (): Promise<{ record: ProjectRecord | null; scope: ResolvedProjectAccessScope | null }> => {\n const call = await apiCall<{ items?: ProjectRecord[]; reason?: unknown }>(\n `/api/staff/timesheets/time-projects?ids=${projectId}&pageSize=1`,\n )\n if (call.status === 404 && call.result?.reason === NO_PROJECT_ACCESS_REASON) {\n throw new ProjectAccessDeniedError(readResolvedProjectAccessScope(call.response))\n }\n if (!call.ok) {\n throw new Error(t('staff.timesheets.projects.errors.load', 'Failed to load project.'))\n }\n const record = Array.isArray(call.result?.items) ? call.result.items[0] : null\n return { record: record ?? null, scope: readResolvedProjectAccessScope(call.response) }\n }, [projectId, t])\n\n /**\n * D-3: the change-currency action writes to the project directly, so it bumped\n * both the currency and `updated_at`. Reload the record \u2014 the form derives its\n * optimistic-lock version from `initialValues.updatedAt`, and a stale one would\n * fail the next save with a conflict this same user had just caused. `CrudForm`\n * merges refreshed initial values without discarding fields being edited.\n */\n const handleCurrencyChanged = React.useCallback(() => {\n if (!projectId) return\n void fetchProject()\n .then(({ record }) => { if (record) setInitialValues(toValues(record)) })\n .catch(() => {\n // Best effort: the optimistic-lock conflict bar remains the backstop.\n })\n }, [fetchProject, projectId])\n\n const formSchema = React.useMemo(() => createProjectFormSchema(), [])\n const fields = React.useMemo(\n () => createProjectFormFields(t, { onCurrencyChanged: handleCurrencyChanged }),\n [t, handleCurrencyChanged],\n )\n const groups = React.useMemo(() => createProjectFormGroups(t), [t])\n\n // The organization switcher settles the scope just after mount, bumping the\n // scope version from 0 and reloading the project. A screen resolved before\n // that stays up until the answer arrives: falling back to the full-page\n // loader would unmount it and drop its local state \u2014 the form's unsaved\n // edits, or an access request the user has just sent. A switch between two\n // known scopes still goes through the loader, so no form outlives the scope\n // it was loaded for. The decision looks at the load this one replaces,\n // settled or not: a version-0 load cancelled by a quick second switch must\n // not let that switch through.\n const lastLoadRef = React.useRef<{ projectId: string; scopeVersion: number; scopeKey: string } | null>(null)\n React.useEffect(() => {\n if (!projectId) return\n let cancelled = false\n const replaced = lastLoadRef.current\n lastLoadRef.current = { projectId, scopeVersion, scopeKey: projectAccessScopeKey }\n const keepScreen = replaced !== null\n && replaced.projectId === projectId\n && replaced.scopeVersion === 0\n && replaced.scopeKey === projectAccessScopeKey\n async function load() {\n if (!keepScreen) setLoading(true)\n const settle = (outcome: { values?: ProjectFormValues; accessDenied?: boolean; isNotFound?: boolean; error?: string }) => {\n if (cancelled) return\n setAccessDeniedScopeKey(outcome.accessDenied === true ? projectAccessScopeKey : null)\n setIsNotFound(outcome.isNotFound === true)\n setError(outcome.error ?? null)\n if (outcome.values) setInitialValues(outcome.values)\n setLoadedScopeKey(projectAccessScopeKey)\n setLoading(false)\n }\n try {\n const { record, scope } = await fetchProject()\n if (!cancelled) resolveScope(scope)\n if (!record) {\n settle({ isNotFound: true })\n return\n }\n settle({ values: toValues(record) })\n } catch (err) {\n if (err instanceof ProjectAccessDeniedError) {\n if (!cancelled) resolveScope(err.resolvedScope)\n settle({ accessDenied: true })\n return\n }\n settle({\n error: err instanceof Error\n ? err.message\n : t('staff.timesheets.projects.errors.load', 'Failed to load project.'),\n })\n }\n }\n load()\n return () => { cancelled = true }\n }, [projectId, fetchProject, scopeVersion, t, projectAccessScopeKey, resolveScope])\n\n if (!accessDenied && (loading || loadedScopeKey !== projectAccessScopeKey)) {\n return <Page><PageBody><LoadingMessage label={t('staff.timesheets.projects.loading', 'Loading project...')} /></PageBody></Page>\n }\n\n // Screen 17 \u2014 the caller may not open this project. Same guard state the\n // detail page renders; it names neither the customer nor the project.\n if (accessDenied) {\n return <NoProjectAccess timeProjectId={projectId} resolvedScope={resolvedScope} />\n }\n\n if (isNotFound) {\n return (\n <Page>\n <PageBody>\n <RecordNotFoundState\n label={t('staff.timesheets.projects.errors.notFound', 'Project not found.')}\n backHref={LIST_HREF}\n backLabel={t('staff.timesheets.projects.actions.backToList', 'Back to projects')}\n />\n </PageBody>\n </Page>\n )\n }\n\n if (error || !initialValues) {\n return <Page><PageBody><ErrorMessage label={error ?? t('staff.timesheets.projects.errors.load', 'Failed to load project.')} /></PageBody></Page>\n }\n\n const detailHref = `/backend/staff/time-tracking/projects/${projectId}`\n\n return (\n <Page>\n <PageBody>\n <CrudForm<ProjectFormValues>\n title={t('staff.time_tracking.projects.form.editTitle', 'Edit project')}\n titleHeadingLevel={1}\n backHref={detailHref}\n cancelHref={detailHref}\n fields={fields}\n groups={groups}\n schema={formSchema}\n initialValues={initialValues}\n entityIds={[E.staff.staff_time_project]}\n injectionSpotId={extensionPoints.hosts.projectForm.spotId}\n submitLabel={t('staff.timesheets.projects.form.actions.save', 'Save')}\n onDelete={async () => {\n await deleteCrud('staff/timesheets/time-projects', projectId!, {\n errorMessage: t('staff.timesheets.projects.errors.delete', 'Failed to delete project.'),\n })\n flash(t('staff.timesheets.projects.messages.deleted', 'Project deleted.'), 'success')\n router.push(LIST_HREF)\n }}\n onSubmit={async (values) => {\n if (!values.name?.trim() || !values.code?.trim() || !values.customerId) {\n const fieldErrors: Record<string, string> = {}\n if (!values.name?.trim()) {\n fieldErrors.name = t('staff.time_tracking.projects.validation.nameRequired', 'A project name is required.')\n }\n if (!values.code?.trim()) {\n fieldErrors.code = t('staff.time_tracking.projects.validation.codeRequired', 'A project code is required.')\n }\n if (!values.customerId) {\n fieldErrors.customerId = t('staff.time_tracking.projects.validation.customerRequired', 'A customer is required.')\n }\n throw createCrudFormError(\n t('staff.time_tracking.projects.errors.required', 'Fill in the required fields.'),\n fieldErrors,\n )\n }\n\n const payload = buildProjectPayload({ ...values, id: projectId })\n\n await updateCrud('staff/timesheets/time-projects', payload, {\n errorMessage: t('staff.timesheets.projects.errors.save', 'Failed to save project.'),\n })\n\n flash(t('staff.timesheets.projects.messages.saved', 'Project saved.'), 'success')\n router.push(detailHref)\n }}\n versionHistory={{\n resourceKind: 'staff.timesheets.time_project',\n resourceId: projectId!,\n canUndoRedo: true,\n autoCheckAcl: true,\n }}\n />\n </PageBody>\n </Page>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -32,6 +32,8 @@ import { formatCurrency } from "@open-mercato/ui/utils/format";
|
|
|
32
32
|
import { KpiCard, Sparkline } from "@open-mercato/ui/backend/charts";
|
|
33
33
|
import { ProjectTeamDrawer } from "../../../../../lib/time-tracking-ui/ProjectTeamDrawer.js";
|
|
34
34
|
import { NoProjectAccess } from "../../../../../lib/time-tracking-ui/NoProjectAccess.js";
|
|
35
|
+
import { useProjectAccessScope } from "../../../../../lib/time-tracking-ui/projectAccessScope.js";
|
|
36
|
+
import { readResolvedProjectAccessScope } from "../../../../../lib/time-tracking-ui/projectAccessScopeHeaders.js";
|
|
35
37
|
import { ProjectBudgetCell } from "../../../../../lib/timesheets-projects-ui/ProjectBudgetCell.js";
|
|
36
38
|
import { computeBudgetBurn } from "../../../../../lib/timesheets-projects/budgetBurn.js";
|
|
37
39
|
import { resolveProjectColorHex } from "../../../../../lib/timesheets-ui/colors.js";
|
|
@@ -69,6 +71,7 @@ function TimesheetProjectDetailPage({ params }) {
|
|
|
69
71
|
translateRef.current = t;
|
|
70
72
|
}, [t]);
|
|
71
73
|
const scopeVersion = useOrganizationScopeVersion();
|
|
74
|
+
const { scopeKey: projectAccessScopeKey, resolvedScope, resolveScope } = useProjectAccessScope(projectId);
|
|
72
75
|
const { confirm, ConfirmDialogElement } = useConfirmDialog();
|
|
73
76
|
const router = useRouter();
|
|
74
77
|
const pathname = usePathname();
|
|
@@ -77,9 +80,9 @@ function TimesheetProjectDetailPage({ params }) {
|
|
|
77
80
|
const [loading, setLoading] = React.useState(true);
|
|
78
81
|
const [error, setError] = React.useState(null);
|
|
79
82
|
const [isNotFound, setIsNotFound] = React.useState(false);
|
|
80
|
-
const [
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
+
const [accessDeniedScopeKey, setAccessDeniedScopeKey] = React.useState(null);
|
|
84
|
+
const [loadedScopeKey, setLoadedScopeKey] = React.useState(null);
|
|
85
|
+
const accessDenied = accessDeniedScopeKey === projectAccessScopeKey;
|
|
83
86
|
const [employees, setEmployees] = React.useState([]);
|
|
84
87
|
const [employeesLoading, setEmployeesLoading] = React.useState(false);
|
|
85
88
|
const [expandedCards, setExpandedCards] = React.useState(/* @__PURE__ */ new Set());
|
|
@@ -245,11 +248,12 @@ function TimesheetProjectDetailPage({ params }) {
|
|
|
245
248
|
const call = await apiCall(
|
|
246
249
|
`/api/staff/timesheets/time-projects?${queryParams.toString()}`
|
|
247
250
|
);
|
|
251
|
+
if (!cancelled) resolveScope(readResolvedProjectAccessScope(call.response));
|
|
248
252
|
if (call.status === 404 && readDenialReason(call.result) === NO_PROJECT_ACCESS_REASON) {
|
|
249
|
-
if (!cancelled)
|
|
253
|
+
if (!cancelled) setAccessDeniedScopeKey(projectAccessScopeKey);
|
|
250
254
|
return;
|
|
251
255
|
}
|
|
252
|
-
if (!cancelled)
|
|
256
|
+
if (!cancelled) setAccessDeniedScopeKey(null);
|
|
253
257
|
if (!call.ok) {
|
|
254
258
|
throw new Error(translateRef.current("staff.timesheets.projects.errors.load", "Failed to load project."));
|
|
255
259
|
}
|
|
@@ -262,12 +266,13 @@ function TimesheetProjectDetailPage({ params }) {
|
|
|
262
266
|
if (!cancelled) setProject(record);
|
|
263
267
|
} catch (loadError) {
|
|
264
268
|
if (!cancelled) {
|
|
269
|
+
setAccessDeniedScopeKey(null);
|
|
265
270
|
setError(loadError instanceof Error ? loadError.message : translateRef.current("staff.timesheets.projects.errors.load", "Failed to load project."));
|
|
266
271
|
}
|
|
267
272
|
} finally {
|
|
268
273
|
if (!cancelled) {
|
|
274
|
+
setLoadedScopeKey(projectAccessScopeKey);
|
|
269
275
|
setLoading(false);
|
|
270
|
-
setSettledProjectId(projectId ?? null);
|
|
271
276
|
}
|
|
272
277
|
}
|
|
273
278
|
}
|
|
@@ -275,7 +280,7 @@ function TimesheetProjectDetailPage({ params }) {
|
|
|
275
280
|
return () => {
|
|
276
281
|
cancelled = true;
|
|
277
282
|
};
|
|
278
|
-
}, [projectId, scopeVersion]);
|
|
283
|
+
}, [projectId, scopeVersion, projectAccessScopeKey, resolveScope]);
|
|
279
284
|
const loadEmployees = React.useCallback(async () => {
|
|
280
285
|
if (!projectId || accessDenied) return;
|
|
281
286
|
setEmployeesLoading(true);
|
|
@@ -491,9 +496,9 @@ function TimesheetProjectDetailPage({ params }) {
|
|
|
491
496
|
}
|
|
492
497
|
}, [handleAddEmployee]);
|
|
493
498
|
if (accessDenied) {
|
|
494
|
-
return /* @__PURE__ */ jsx(NoProjectAccess, { timeProjectId: projectId });
|
|
499
|
+
return /* @__PURE__ */ jsx(NoProjectAccess, { timeProjectId: projectId, resolvedScope });
|
|
495
500
|
}
|
|
496
|
-
if (loading ||
|
|
501
|
+
if (loading || loadedScopeKey !== projectAccessScopeKey) {
|
|
497
502
|
return /* @__PURE__ */ jsx(Page, { children: /* @__PURE__ */ jsx(PageBody, { children: /* @__PURE__ */ jsx(LoadingMessage, { label: t("staff.timesheets.projects.loading", "Loading project...") }) }) });
|
|
498
503
|
}
|
|
499
504
|
if (isNotFound) {
|