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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/api_keys/api/keys/route.js +15 -1
  3. package/dist/modules/api_keys/api/keys/route.js.map +2 -2
  4. package/dist/modules/auth/api/users/route.js +16 -1
  5. package/dist/modules/auth/api/users/route.js.map +2 -2
  6. package/dist/modules/auth/backend/users/page.js +13 -2
  7. package/dist/modules/auth/backend/users/page.js.map +2 -2
  8. package/dist/modules/catalog/commands/products.js +4 -2
  9. package/dist/modules/catalog/commands/products.js.map +2 -2
  10. package/dist/modules/customer_accounts/api/admin/roles.js +25 -2
  11. package/dist/modules/customer_accounts/api/admin/roles.js.map +2 -2
  12. package/dist/modules/customer_accounts/api/admin/users.js +24 -7
  13. package/dist/modules/customer_accounts/api/admin/users.js.map +2 -2
  14. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +77 -15
  15. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
  16. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js +12 -1
  17. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js.map +2 -2
  18. package/dist/modules/customer_accounts/data/validators.js +2 -1
  19. package/dist/modules/customer_accounts/data/validators.js.map +2 -2
  20. package/dist/modules/customer_accounts/lib/adminOrganizationScope.js +46 -0
  21. package/dist/modules/customer_accounts/lib/adminOrganizationScope.js.map +7 -0
  22. package/dist/modules/customers/api/customer-dictionary-entries/route.js +86 -0
  23. package/dist/modules/customers/api/customer-dictionary-entries/route.js.map +7 -0
  24. package/dist/modules/customers/api/dictionaries/[kind]/route.js +29 -7
  25. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  26. package/dist/modules/customers/cli.js +28 -110
  27. package/dist/modules/customers/cli.js.map +3 -3
  28. package/dist/modules/customers/components/DictionarySettings.js +1 -1
  29. package/dist/modules/customers/components/DictionarySettings.js.map +1 -1
  30. package/dist/modules/customers/components/detail/ManageTagsDialog.js +1 -1
  31. package/dist/modules/customers/components/detail/ManageTagsDialog.js.map +2 -2
  32. package/dist/modules/customers/components/detail/TaskDialog.js +14 -1
  33. package/dist/modules/customers/components/detail/TaskDialog.js.map +2 -2
  34. package/dist/modules/customers/components/detail/TaskForm.js +1 -16
  35. package/dist/modules/customers/components/detail/TaskForm.js.map +2 -2
  36. package/dist/modules/customers/components/detail/TasksSection.js +38 -10
  37. package/dist/modules/customers/components/detail/TasksSection.js.map +2 -2
  38. package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js +12 -5
  39. package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js.map +2 -2
  40. package/dist/modules/customers/components/detail/hooks/useInteractions.js +3 -13
  41. package/dist/modules/customers/components/detail/hooks/useInteractions.js.map +2 -2
  42. package/dist/modules/customers/lib/dictionaryDefaults.js +134 -0
  43. package/dist/modules/customers/lib/dictionaryDefaults.js.map +7 -0
  44. package/dist/modules/customers/lib/dictionaryLabels.js +59 -0
  45. package/dist/modules/customers/lib/dictionaryLabels.js.map +7 -0
  46. package/dist/modules/customers/lib/interactionCompatibility.js +1 -0
  47. package/dist/modules/customers/lib/interactionCompatibility.js.map +2 -2
  48. package/dist/modules/customers/translations.js +9 -0
  49. package/dist/modules/customers/translations.js.map +7 -0
  50. package/dist/modules/directory/api/organization-branding/route.js +6 -9
  51. package/dist/modules/directory/api/organization-branding/route.js.map +2 -2
  52. package/dist/modules/directory/backend/directory/branding/page.js +15 -3
  53. package/dist/modules/directory/backend/directory/branding/page.js.map +2 -2
  54. package/dist/modules/eudr/index.js +13 -0
  55. package/dist/modules/eudr/index.js.map +2 -2
  56. package/dist/modules/staff/api/timesheets/access-requests/route.js +11 -5
  57. package/dist/modules/staff/api/timesheets/access-requests/route.js.map +2 -2
  58. package/dist/modules/staff/api/timesheets/time-projects/route.js +19 -12
  59. package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
  60. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js +21 -12
  61. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js.map +2 -2
  62. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +14 -9
  63. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  64. package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js +31 -10
  65. package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js.map +2 -2
  66. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js +64 -0
  67. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js.map +7 -0
  68. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js +13 -0
  69. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js.map +7 -0
  70. package/dist/modules/sync_excel/index.js +11 -1
  71. package/dist/modules/sync_excel/index.js.map +2 -2
  72. package/dist/modules/translations/di.js +2 -0
  73. package/dist/modules/translations/di.js.map +2 -2
  74. package/dist/modules/wms/api/listEnrichers.js +3 -1
  75. package/dist/modules/wms/api/listEnrichers.js.map +2 -2
  76. package/dist/modules/wms/index.js +1 -1
  77. package/dist/modules/wms/index.js.map +2 -2
  78. package/package.json +7 -7
  79. package/src/modules/api_keys/api/keys/route.ts +15 -1
  80. package/src/modules/api_keys/i18n/de.json +1 -0
  81. package/src/modules/api_keys/i18n/en.json +1 -0
  82. package/src/modules/api_keys/i18n/es.json +1 -0
  83. package/src/modules/api_keys/i18n/ko.json +1 -0
  84. package/src/modules/api_keys/i18n/pl.json +1 -0
  85. package/src/modules/auth/api/users/route.ts +18 -1
  86. package/src/modules/auth/backend/users/page.tsx +14 -2
  87. package/src/modules/auth/i18n/de.json +1 -0
  88. package/src/modules/auth/i18n/en.json +1 -0
  89. package/src/modules/auth/i18n/es.json +1 -0
  90. package/src/modules/auth/i18n/ko.json +1 -0
  91. package/src/modules/auth/i18n/pl.json +1 -0
  92. package/src/modules/catalog/commands/products.ts +4 -1
  93. package/src/modules/customer_accounts/api/admin/roles.ts +26 -2
  94. package/src/modules/customer_accounts/api/admin/users.ts +26 -7
  95. package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +96 -13
  96. package/src/modules/customer_accounts/backend/customer_accounts/users/page.tsx +9 -1
  97. package/src/modules/customer_accounts/data/validators.ts +1 -0
  98. package/src/modules/customer_accounts/i18n/de.json +10 -0
  99. package/src/modules/customer_accounts/i18n/en.json +10 -0
  100. package/src/modules/customer_accounts/i18n/es.json +10 -0
  101. package/src/modules/customer_accounts/i18n/ko.json +10 -0
  102. package/src/modules/customer_accounts/i18n/pl.json +10 -0
  103. package/src/modules/customer_accounts/lib/adminOrganizationScope.ts +71 -0
  104. package/src/modules/customers/api/customer-dictionary-entries/route.ts +82 -0
  105. package/src/modules/customers/api/dictionaries/[kind]/route.ts +29 -6
  106. package/src/modules/customers/cli.ts +23 -124
  107. package/src/modules/customers/components/DictionarySettings.tsx +1 -1
  108. package/src/modules/customers/components/detail/ManageTagsDialog.tsx +1 -1
  109. package/src/modules/customers/components/detail/TaskDialog.tsx +15 -1
  110. package/src/modules/customers/components/detail/TaskForm.tsx +1 -17
  111. package/src/modules/customers/components/detail/TasksSection.tsx +44 -14
  112. package/src/modules/customers/components/detail/hooks/useCustomerDictionary.ts +14 -8
  113. package/src/modules/customers/components/detail/hooks/useInteractions.ts +3 -4
  114. package/src/modules/customers/i18n/de.json +81 -0
  115. package/src/modules/customers/i18n/en.json +81 -0
  116. package/src/modules/customers/i18n/es.json +81 -0
  117. package/src/modules/customers/i18n/ko.json +81 -0
  118. package/src/modules/customers/i18n/pl.json +81 -0
  119. package/src/modules/customers/lib/dictionaryDefaults.ts +137 -0
  120. package/src/modules/customers/lib/dictionaryLabels.ts +73 -0
  121. package/src/modules/customers/lib/interactionCompatibility.ts +1 -0
  122. package/src/modules/customers/translations.ts +5 -0
  123. package/src/modules/directory/api/organization-branding/route.ts +5 -8
  124. package/src/modules/directory/backend/directory/branding/page.tsx +15 -3
  125. package/src/modules/eudr/index.ts +13 -0
  126. package/src/modules/staff/api/timesheets/access-requests/route.ts +13 -14
  127. package/src/modules/staff/api/timesheets/time-projects/route.ts +22 -13
  128. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.tsx +25 -14
  129. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +14 -13
  130. package/src/modules/staff/lib/time-tracking-ui/NoProjectAccess.tsx +30 -8
  131. package/src/modules/staff/lib/time-tracking-ui/projectAccessScope.ts +102 -0
  132. package/src/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.ts +15 -0
  133. package/src/modules/sync_excel/index.ts +10 -0
  134. package/src/modules/translations/di.ts +2 -0
  135. package/src/modules/wms/api/listEnrichers.ts +5 -2
  136. package/src/modules/wms/index.ts +1 -1
@@ -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 [accessDenied, setAccessDenied] = React.useState(false);
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
- setAccessDenied(outcome.accessDenied === true);
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": "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",
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 [accessDeniedProjectId, setAccessDeniedProjectId] = React.useState(null);
81
- const accessDenied = accessDeniedProjectId !== null && accessDeniedProjectId === projectId;
82
- const [settledProjectId, setSettledProjectId] = React.useState(null);
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) setAccessDeniedProjectId(projectId ?? null);
253
+ if (!cancelled) setAccessDeniedScopeKey(projectAccessScopeKey);
250
254
  return;
251
255
  }
252
- if (!cancelled) setAccessDeniedProjectId(null);
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 || settledProjectId !== (projectId ?? null)) {
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) {