@open-mercato/core 0.10.1-develop.7836.1.0ef0f453c6 → 0.10.1-develop.7841.1.af87ac614d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customer_accounts/api/admin/roles.js +25 -2
  3. package/dist/modules/customer_accounts/api/admin/roles.js.map +2 -2
  4. package/dist/modules/customer_accounts/api/admin/users.js +24 -7
  5. package/dist/modules/customer_accounts/api/admin/users.js.map +2 -2
  6. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +77 -15
  7. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
  8. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js +12 -1
  9. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js.map +2 -2
  10. package/dist/modules/customer_accounts/data/validators.js +2 -1
  11. package/dist/modules/customer_accounts/data/validators.js.map +2 -2
  12. package/dist/modules/customer_accounts/lib/adminOrganizationScope.js +46 -0
  13. package/dist/modules/customer_accounts/lib/adminOrganizationScope.js.map +7 -0
  14. package/dist/modules/customers/api/customer-dictionary-entries/route.js +86 -0
  15. package/dist/modules/customers/api/customer-dictionary-entries/route.js.map +7 -0
  16. package/dist/modules/customers/api/dictionaries/[kind]/route.js +29 -7
  17. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  18. package/dist/modules/customers/cli.js +28 -110
  19. package/dist/modules/customers/cli.js.map +3 -3
  20. package/dist/modules/customers/components/DictionarySettings.js +1 -1
  21. package/dist/modules/customers/components/DictionarySettings.js.map +1 -1
  22. package/dist/modules/customers/components/detail/ManageTagsDialog.js +1 -1
  23. package/dist/modules/customers/components/detail/ManageTagsDialog.js.map +2 -2
  24. package/dist/modules/customers/components/detail/TaskDialog.js +14 -1
  25. package/dist/modules/customers/components/detail/TaskDialog.js.map +2 -2
  26. package/dist/modules/customers/components/detail/TaskForm.js +1 -16
  27. package/dist/modules/customers/components/detail/TaskForm.js.map +2 -2
  28. package/dist/modules/customers/components/detail/TasksSection.js +38 -10
  29. package/dist/modules/customers/components/detail/TasksSection.js.map +2 -2
  30. package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js +12 -5
  31. package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js.map +2 -2
  32. package/dist/modules/customers/components/detail/hooks/useInteractions.js +3 -13
  33. package/dist/modules/customers/components/detail/hooks/useInteractions.js.map +2 -2
  34. package/dist/modules/customers/lib/dictionaryDefaults.js +134 -0
  35. package/dist/modules/customers/lib/dictionaryDefaults.js.map +7 -0
  36. package/dist/modules/customers/lib/dictionaryLabels.js +59 -0
  37. package/dist/modules/customers/lib/dictionaryLabels.js.map +7 -0
  38. package/dist/modules/customers/lib/interactionCompatibility.js +1 -0
  39. package/dist/modules/customers/lib/interactionCompatibility.js.map +2 -2
  40. package/dist/modules/customers/translations.js +9 -0
  41. package/dist/modules/customers/translations.js.map +7 -0
  42. package/dist/modules/eudr/index.js +13 -0
  43. package/dist/modules/eudr/index.js.map +2 -2
  44. package/dist/modules/staff/api/timesheets/access-requests/route.js +11 -5
  45. package/dist/modules/staff/api/timesheets/access-requests/route.js.map +2 -2
  46. package/dist/modules/staff/api/timesheets/time-projects/route.js +19 -12
  47. package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
  48. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js +21 -12
  49. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js.map +2 -2
  50. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +14 -9
  51. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  52. package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js +31 -10
  53. package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js.map +2 -2
  54. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js +64 -0
  55. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js.map +7 -0
  56. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js +13 -0
  57. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js.map +7 -0
  58. package/dist/modules/sync_excel/index.js +11 -1
  59. package/dist/modules/sync_excel/index.js.map +2 -2
  60. package/dist/modules/translations/di.js +2 -0
  61. package/dist/modules/translations/di.js.map +2 -2
  62. package/package.json +7 -7
  63. package/src/modules/customer_accounts/api/admin/roles.ts +26 -2
  64. package/src/modules/customer_accounts/api/admin/users.ts +26 -7
  65. package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +96 -13
  66. package/src/modules/customer_accounts/backend/customer_accounts/users/page.tsx +9 -1
  67. package/src/modules/customer_accounts/data/validators.ts +1 -0
  68. package/src/modules/customer_accounts/i18n/de.json +10 -0
  69. package/src/modules/customer_accounts/i18n/en.json +10 -0
  70. package/src/modules/customer_accounts/i18n/es.json +10 -0
  71. package/src/modules/customer_accounts/i18n/ko.json +10 -0
  72. package/src/modules/customer_accounts/i18n/pl.json +10 -0
  73. package/src/modules/customer_accounts/lib/adminOrganizationScope.ts +71 -0
  74. package/src/modules/customers/api/customer-dictionary-entries/route.ts +82 -0
  75. package/src/modules/customers/api/dictionaries/[kind]/route.ts +29 -6
  76. package/src/modules/customers/cli.ts +23 -124
  77. package/src/modules/customers/components/DictionarySettings.tsx +1 -1
  78. package/src/modules/customers/components/detail/ManageTagsDialog.tsx +1 -1
  79. package/src/modules/customers/components/detail/TaskDialog.tsx +15 -1
  80. package/src/modules/customers/components/detail/TaskForm.tsx +1 -17
  81. package/src/modules/customers/components/detail/TasksSection.tsx +44 -14
  82. package/src/modules/customers/components/detail/hooks/useCustomerDictionary.ts +14 -8
  83. package/src/modules/customers/components/detail/hooks/useInteractions.ts +3 -4
  84. package/src/modules/customers/i18n/de.json +81 -0
  85. package/src/modules/customers/i18n/en.json +81 -0
  86. package/src/modules/customers/i18n/es.json +81 -0
  87. package/src/modules/customers/i18n/ko.json +81 -0
  88. package/src/modules/customers/i18n/pl.json +81 -0
  89. package/src/modules/customers/lib/dictionaryDefaults.ts +137 -0
  90. package/src/modules/customers/lib/dictionaryLabels.ts +73 -0
  91. package/src/modules/customers/lib/interactionCompatibility.ts +1 -0
  92. package/src/modules/customers/translations.ts +5 -0
  93. package/src/modules/eudr/index.ts +13 -0
  94. package/src/modules/staff/api/timesheets/access-requests/route.ts +13 -14
  95. package/src/modules/staff/api/timesheets/time-projects/route.ts +22 -13
  96. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.tsx +25 -14
  97. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +14 -13
  98. package/src/modules/staff/lib/time-tracking-ui/NoProjectAccess.tsx +30 -8
  99. package/src/modules/staff/lib/time-tracking-ui/projectAccessScope.ts +102 -0
  100. package/src/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.ts +15 -0
  101. package/src/modules/sync_excel/index.ts +10 -0
  102. package/src/modules/translations/di.ts +2 -0
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
2
2
  import { z } from "zod";
3
3
  import { makeCrudRoute } from "@open-mercato/shared/lib/crud/factory";
4
4
  import { staffTimeProjectCrudEvents } from "../../../lib/crud.js";
5
+ import { PROJECT_SCOPE_TENANT_HEADER, PROJECT_SCOPE_ORGANIZATION_HEADER } from "../../../lib/time-tracking-ui/projectAccessScopeHeaders.js";
5
6
  import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
6
7
  import { resolveCrudRecordId, parseScopedCommandInput } from "@open-mercato/shared/lib/api/scoped";
7
8
  import { escapeLikePattern } from "@open-mercato/shared/lib/db/escapeLikePattern";
@@ -352,19 +353,21 @@ async function buildAccessDenialResponse() {
352
353
  );
353
354
  }
354
355
  async function resolveProjectAccessDenial(req) {
356
+ const unresolved = { denial: null, scope: null };
355
357
  const url = new URL(req.url);
356
358
  const rawIds = url.searchParams.get("ids") ?? url.searchParams.get("id");
357
- if (!isIdsParamProvided(rawIds)) return null;
359
+ if (!isIdsParamProvided(rawIds)) return unresolved;
358
360
  const requestedIds = parseIdsParam(rawIds);
359
- if (requestedIds.length === 0) return null;
361
+ if (requestedIds.length === 0) return unresolved;
360
362
  try {
361
363
  const container = await createRequestContainer();
362
364
  const auth = await getAuthFromRequest(req);
363
- if (!auth) return null;
365
+ if (!auth) return unresolved;
364
366
  const orgScope = await resolveOrganizationScopeForRequest({ container, auth, request: req });
365
367
  const tenantId = orgScope?.tenantId ?? auth.tenantId ?? null;
366
368
  const organizationId = resolveSingleOrganizationIdOrDeny(orgScope, auth) ?? null;
367
- if (!tenantId || !organizationId) return null;
369
+ if (!tenantId || !organizationId) return unresolved;
370
+ const scope = orgScope?.tenantId && orgScope.selectedId && orgScope.selectionRejected !== true ? { tenantId: orgScope.tenantId, organizationId: orgScope.selectedId } : null;
368
371
  const access = await resolveRequestProjectAccess(
369
372
  req,
370
373
  () => loadProjectAccess(
@@ -374,18 +377,22 @@ async function resolveProjectAccessDenial(req) {
374
377
  organizationId
375
378
  )
376
379
  );
377
- if (access.canManageAll) return null;
378
- if (requestedIds.some((id) => access.projectIds.includes(id))) return null;
379
- return await buildAccessDenialResponse();
380
+ if (access.canManageAll) return { denial: null, scope };
381
+ if (requestedIds.some((id) => access.projectIds.includes(id))) return { denial: null, scope };
382
+ return { denial: await buildAccessDenialResponse(), scope };
380
383
  } catch (err) {
381
384
  logger.error("staff.timesheets.time-projects denial probe failed", { err });
382
- return await buildAccessDenialResponse();
385
+ return { denial: await buildAccessDenialResponse(), scope: null };
383
386
  }
384
387
  }
385
388
  async function GET(req) {
386
- const denial = await resolveProjectAccessDenial(req);
387
- if (denial) return denial;
388
- return crud.GET(req);
389
+ const { denial, scope } = await resolveProjectAccessDenial(req);
390
+ const response = denial ?? await crud.GET(req);
391
+ if (scope) {
392
+ response.headers.set(PROJECT_SCOPE_TENANT_HEADER, scope.tenantId);
393
+ response.headers.set(PROJECT_SCOPE_ORGANIZATION_HEADER, scope.organizationId);
394
+ }
395
+ return response;
389
396
  }
390
397
  const POST = crud.POST;
391
398
  const PUT = crud.PUT;
@@ -451,7 +458,7 @@ const openApi = {
451
458
  ...baseOpenApi.methods?.GET ? {
452
459
  GET: {
453
460
  ...baseOpenApi.methods.GET,
454
- description: `${baseOpenApi.methods.GET.description ?? ""} Rows are intersected with the caller's project access: without staff.timesheets.projects.manage only projects the caller is an active member of are returned. Asking for a single id the caller may not see answers 404 with reason=${NO_PROJECT_ACCESS_REASON} and never names the project or its customer; the same 404 covers ids that do not exist, so the endpoint cannot be used to enumerate projects.`.trim(),
461
+ description: `${baseOpenApi.methods.GET.description ?? ""} Project-specific reads include optional x-om-project-scope-tenant-id and x-om-project-scope-organization-id response headers when the server resolves a scope. These identify the request scope for client hydration; a missing header provides no scope identity. Rows are intersected with the caller's project access: without staff.timesheets.projects.manage only projects the caller is an active member of are returned. Asking for a single id the caller may not see answers 404 with reason=${NO_PROJECT_ACCESS_REASON} and never names the project or its customer; the same 404 covers ids that do not exist, so the endpoint cannot be used to enumerate projects.`.trim(),
455
462
  responses: [
456
463
  ...baseOpenApi.methods.GET.responses ?? [],
457
464
  {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/staff/api/timesheets/time-projects/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { makeCrudRoute, type CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport { staffTimeProjectCrudEvents } from '../../../lib/crud'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveCrudRecordId, parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport { isIdsParamProvided, parseIdsParam } from '@open-mercato/shared/lib/crud/ids'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\nimport { StaffTimeProject, StaffTimeProjectMember, StaffTeamMember } from '../../../data/entities'\nimport { staffTimeProjectCreateSchema, staffTimeProjectUpdateSchema } from '../../../data/validators'\nimport { resolveFeatureAccess } from '../../../lib/time-tracking/featureAccess'\nimport { MANAGE_PROJECTS_FEATURE, resolveProjectAccess, type ProjectAccess } from '../../../lib/time-tracking/access'\nimport { readTimeTrackingSettings } from '../../../lib/time-tracking/settings'\nimport { buildSqlInClause } from '../../../lib/time-tracking/sqlInClause'\nimport { sanitizeSearchTerm, parseBooleanFlag } from '../../helpers'\nimport { createStaffCrudOpenApi, createPagedListResponseSchema, defaultOkResponseSchema } from '../../openapi'\nimport { E } from '#generated/entities.ids.generated'\n\nconst logger = createLogger('staff').child({ component: 'api/timesheets/time-projects' })\n\nconst F = {\n id: 'id',\n tenant_id: 'tenant_id',\n organization_id: 'organization_id',\n name: 'name',\n code: 'code',\n description: 'description',\n project_type: 'project_type',\n color: 'color',\n status: 'status',\n customer_id: 'customer_id',\n customer_snapshot: 'customer_snapshot',\n owner_user_id: 'owner_user_id',\n cost_center: 'cost_center',\n start_date: 'start_date',\n hourly_rate: 'hourly_rate',\n currency_code: 'currency_code',\n billable_by_default: 'billable_by_default',\n budget_kind: 'budget_kind',\n budget_value: 'budget_value',\n budget_warn_at_percent: 'budget_warn_at_percent',\n default_assignee_staff_member_id: 'default_assignee_staff_member_id',\n created_at: 'created_at',\n updated_at: 'updated_at',\n deleted_at: 'deleted_at',\n} as const\n\nconst routeMetadata = {\n GET: { requireAuth: true, requireFeatures: ['staff.timesheets.projects.view'] },\n POST: { requireAuth: true, requireFeatures: ['staff.timesheets.projects.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['staff.timesheets.projects.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['staff.timesheets.projects.manage'] },\n}\n\nexport const metadata = routeMetadata\n\nconst rawBodySchema = z.object({}).passthrough()\n\nconst listSchema = z\n .object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n q: z.string().optional(),\n ids: z.string().optional(),\n projectType: z.string().optional(),\n status: z.string().optional(),\n customerId: z.string().uuid().optional(),\n mine: z.string().optional(),\n include: z.string().optional(),\n sortField: z.string().optional(),\n sortDir: z.enum(['asc', 'desc']).optional(),\n })\n .passthrough()\n\n/**\n * Discriminator returned with the 404 a project-scoped read answers when the\n * caller may not see the record. It is the whole body besides a generic error\n * string: naming the project or the customer here would confirm the resource to\n * someone who has no right to it (screen 17 note 1).\n *\n * Denied and genuinely-missing are deliberately indistinguishable for a caller\n * without `staff.timesheets.projects.manage` \u2014 both answer this same 404 \u2014 so the\n * endpoint cannot be used to enumerate which project ids exist. The discriminator\n * only tells the client \"you are not a member\", which is what screen 17 needs to\n * render the request-access affordance; a manager still gets a plain 200-empty\n * for an id that really does not exist.\n */\nexport const NO_PROJECT_ACCESS_REASON = 'no_project_access'\n\n/**\n * Non-UUID sentinel used as the \"match nothing\" id filter. Kept in the shape the\n * `mine=true` branch has always used so both narrowing paths behave identically.\n */\nconst IMPOSSIBLE_PROJECT_ID = '00000000-0000-0000-0000-000000000000'\n\nconst DENIED_ACCESS: ProjectAccess = { canManageAll: false, projectIds: [], staffMemberId: null }\n\ntype RbacServiceLike = {\n getGrantedFeatures?: (\n userId: string,\n options: { tenantId: string | null; organizationId: string | null },\n ) => Promise<string[]>\n userHasAllFeatures?: (\n userId: string,\n required: string[],\n options: { tenantId: string | null; organizationId: string | null },\n ) => Promise<boolean>\n}\n\ntype ContainerLike = { resolve: (name: string) => unknown }\n\n/**\n * Both entry points into a single request \u2014 the denial probe on `GET` and the\n * list narrowing in `buildFilters` \u2014 must agree on what the caller may see, and\n * neither should pay for the other's lookups. The resolved access is therefore\n * memoised per `Request`; the factory hands `buildFilters` the same object the\n * route received.\n */\nconst accessByRequest = new WeakMap<Request, Promise<ProjectAccess>>()\n\nasync function resolveGrantedFeatures(\n container: ContainerLike,\n userId: string,\n scope: { tenantId: string; organizationId: string },\n): Promise<string[]> {\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike | undefined\n if (!rbac?.getGrantedFeatures) return []\n return (await rbac.getGrantedFeatures(userId, scope)) ?? []\n } catch {\n // Fail closed: an unreadable grant set is no grant set, so the caller falls\n // back to their project memberships instead of being treated as a manager.\n return []\n }\n}\n\nasync function resolveUnrestricted(\n container: ContainerLike,\n userId: string,\n scope: { tenantId: string; organizationId: string },\n): Promise<boolean> {\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike | undefined\n if (!rbac?.userHasAllFeatures) return false\n return (await rbac.userHasAllFeatures(userId, [MANAGE_PROJECTS_FEATURE], scope)) === true\n } catch {\n // Fail closed, exactly as the grant lookup does.\n return false\n }\n}\n\nasync function resolveAssignmentGraceDays(\n container: ContainerLike,\n tenantId: string,\n): Promise<number | null> {\n try {\n const configService = container.resolve('moduleConfigService') as ModuleConfigService\n const settings = await readTimeTrackingSettings(configService, { tenantId })\n return settings.access.assignmentGraceDays\n } catch {\n // Fail safe to the documented default rather than widening the window.\n return null\n }\n}\n\nasync function loadProjectAccess(\n container: ContainerLike,\n userId: string | null,\n tenantId: string | null,\n organizationId: string | null,\n): Promise<ProjectAccess> {\n if (!tenantId || !organizationId) return { ...DENIED_ACCESS }\n const scope = { tenantId, organizationId }\n try {\n // One lookup, one authority, and a failure that says so. The two-call version\n // this replaces rescued `canManageAll` but still fed a possibly-empty array\n // into `userFeatures`, which the guards downstream read.\n const access = await resolveFeatureAccess(container, userId, [MANAGE_PROJECTS_FEATURE], scope)\n const em = container.resolve('em') as EntityManager\n return await resolveProjectAccess({\n em: em.fork(),\n userId,\n tenantId,\n organizationId,\n canManageAll: access.allowed,\n userFeatures: access.grantedFeatures,\n assignmentGraceDays: await resolveAssignmentGraceDays(container, tenantId),\n })\n } catch (err) {\n logger.error('staff.timesheets.time-projects access resolution failed', { err })\n return { ...DENIED_ACCESS }\n }\n}\n\nfunction resolveRequestProjectAccess(\n request: Request | undefined,\n load: () => Promise<ProjectAccess>,\n): Promise<ProjectAccess> {\n if (!request) return load()\n const cached = accessByRequest.get(request)\n if (cached) return cached\n const pending = load()\n accessByRequest.set(request, pending)\n return pending\n}\n\nfunction resolveCtxScope(ctx: CrudCtx): { tenantId: string | null; organizationId: string | null } {\n return {\n tenantId: ctx.auth?.tenantId ?? null,\n organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,\n }\n}\n\nasync function resolveListProjectAccess(ctx: CrudCtx): Promise<ProjectAccess> {\n const { tenantId, organizationId } = resolveCtxScope(ctx)\n return resolveRequestProjectAccess(ctx.request, () =>\n loadProjectAccess(ctx.container, ctx.auth?.sub ?? null, tenantId, organizationId),\n )\n}\n\nasync function resolveCallerAssignedProjectIds(\n em: EntityManager,\n userId: string,\n tenantId: string,\n organizationId: string,\n): Promise<string[]> {\n const scopeCtx = { tenantId, organizationId }\n const staffMember = await findOneWithDecryption(\n em.fork(),\n StaffTeamMember,\n { userId, tenantId, organizationId, deletedAt: null },\n {},\n scopeCtx,\n )\n if (!staffMember) return []\n const memberships = await em.fork().find(StaffTimeProjectMember, {\n staffMemberId: staffMember.id,\n tenantId,\n organizationId,\n status: 'active',\n deletedAt: null,\n })\n return Array.from(new Set(memberships.map((m) => m.timeProjectId)))\n}\n\ntype ProjectEntryCountRow = {\n time_project_id: string\n entry_count: string | number\n locked_entry_count: string | number\n}\n\ntype ProjectListItem = {\n id?: unknown\n entryCount?: number\n lockedEntryCount?: number\n currencyLocked?: boolean\n}\n\nfunction toCount(value: string | number | null | undefined): number {\n const parsed = typeof value === 'number' ? value : Number(value ?? 0)\n return Number.isFinite(parsed) ? parsed : 0\n}\n\n/**\n * D-3 read-side signal: currency is read-only once hours have been logged, so the\n * project list/detail response carries the reason alongside the value.\n * `currencyLocked` tells the form to render the field read-only behind the explicit\n * change action, `entryCount` names why, and `lockedEntryCount` says whether even\n * that action will be refused (entries frozen in a closed report).\n *\n * One grouped aggregate for the whole page \u2014 never a per-row count.\n */\nexport async function decorateProjectCurrencyLock(\n items: ProjectListItem[],\n em: EntityManager,\n scope: { tenantId: string; organizationId: string },\n): Promise<void> {\n const ids = Array.from(\n new Set(items.map((item) => item.id).filter((value): value is string => typeof value === 'string' && value.length > 0)),\n )\n if (ids.length === 0) return\n\n const projectIn = buildSqlInClause('time_project_id', ids)\n const rows = (await em.getConnection().execute(\n `\n SELECT time_project_id,\n COUNT(*)::bigint AS entry_count,\n COUNT(locked_report_id)::bigint AS locked_entry_count\n FROM staff_time_entries\n WHERE tenant_id = ?\n AND organization_id = ?\n AND deleted_at IS NULL\n AND ${projectIn.sql}\n GROUP BY time_project_id\n `,\n [scope.tenantId, scope.organizationId, ...projectIn.params],\n )) as ProjectEntryCountRow[]\n\n const counts = new Map(rows.map((row) => [row.time_project_id, row]))\n for (const item of items) {\n const row = typeof item.id === 'string' ? counts.get(item.id) : undefined\n const entryCount = toCount(row?.entry_count)\n item.entryCount = entryCount\n item.lockedEntryCount = toCount(row?.locked_entry_count)\n item.currencyLocked = entryCount > 0\n }\n}\n\n/**\n * The list projection doubles as the detail projection (the edit page re-reads this\n * route with `?id=`), so every column the project form round-trips has to be here.\n */\nexport const timeProjectListFields = [\n F.id,\n F.organization_id,\n F.tenant_id,\n F.name,\n F.code,\n F.description,\n F.project_type,\n F.color,\n F.status,\n F.customer_id,\n F.customer_snapshot,\n F.owner_user_id,\n F.cost_center,\n F.start_date,\n F.hourly_rate,\n F.currency_code,\n F.billable_by_default,\n F.budget_kind,\n F.budget_value,\n F.budget_warn_at_percent,\n F.default_assignee_staff_member_id,\n F.created_at,\n F.updated_at,\n] as const\n\nconst crud = makeCrudRoute({\n metadata: routeMetadata,\n orm: {\n entity: StaffTimeProject,\n idField: 'id',\n orgField: 'organizationId',\n tenantField: 'tenantId',\n softDeleteField: 'deletedAt',\n },\n events: staffTimeProjectCrudEvents,\n indexer: { entityType: 'staff:staff_time_project' },\n enrichers: { entityId: 'staff:staff_time_project' },\n list: {\n schema: listSchema,\n entityId: 'staff:staff_time_project',\n fields: [...timeProjectListFields],\n decorateCustomFields: { entityIds: [E.staff.staff_time_project] },\n sortFieldMap: {\n name: F.name,\n code: F.code,\n status: F.status,\n createdAt: F.created_at,\n updatedAt: F.updated_at,\n startDate: F.start_date,\n },\n buildFilters: async (query, ctx) => {\n const filters: Record<string, unknown> = {}\n let narrowIds: string[] | null = null\n if (typeof query.ids === 'string' && query.ids.trim().length > 0) {\n narrowIds = query.ids\n .split(',')\n .map((value) => value.trim())\n .filter((value) => value.length > 0)\n }\n if (parseBooleanFlag(query.mine) && ctx.auth) {\n const tenantId = ctx.auth.tenantId ?? null\n const organizationId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null\n if (!tenantId || !organizationId) {\n filters[F.id] = { $in: [IMPOSSIBLE_PROJECT_ID] }\n return filters\n }\n const em = ctx.container.resolve('em') as EntityManager\n const assigned = await resolveCallerAssignedProjectIds(\n em,\n ctx.auth.sub,\n tenantId,\n organizationId,\n )\n narrowIds = narrowIds\n ? narrowIds.filter((id) => assigned.includes(id))\n : assigned\n if (narrowIds.length === 0) {\n filters[F.id] = { $in: [IMPOSSIBLE_PROJECT_ID] }\n return filters\n }\n }\n // Every project-scoped read filters through the one resolver: a caller\n // without `staff.timesheets.projects.manage` only ever sees the projects\n // they are an active member of. A non-member gets no row at all \u2014 not a\n // redacted one \u2014 so the customer and project name never leave the server.\n const access = await resolveListProjectAccess(ctx)\n if (!access.canManageAll) {\n narrowIds = narrowIds\n ? narrowIds.filter((id) => access.projectIds.includes(id))\n : [...access.projectIds]\n if (narrowIds.length === 0) {\n filters[F.id] = { $in: [IMPOSSIBLE_PROJECT_ID] }\n return filters\n }\n }\n if (narrowIds && narrowIds.length > 0) {\n filters[F.id] = { $in: narrowIds }\n }\n const term = sanitizeSearchTerm(query.q)\n if (term) {\n filters[F.name] = { $ilike: `%${escapeLikePattern(term)}%` }\n }\n if (typeof query.projectType === 'string' && query.projectType.trim().length > 0) {\n filters[F.project_type] = query.projectType.trim()\n }\n if (typeof query.status === 'string' && query.status.trim().length > 0) {\n filters[F.status] = query.status.trim()\n }\n if (typeof query.customerId === 'string' && query.customerId.trim().length > 0) {\n filters[F.customer_id] = query.customerId.trim()\n }\n return filters\n },\n },\n hooks: {\n afterList: async (res, ctx) => {\n const items = Array.isArray((res as { items?: unknown })?.items)\n ? ((res as { items: ProjectListItem[] }).items)\n : []\n if (items.length === 0) return\n const tenantId = ctx.auth?.tenantId ?? null\n const organizationId = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n if (!tenantId || !organizationId) return\n const em = ctx.container.resolve('em') as EntityManager\n await decorateProjectCurrencyLock(items, em.fork(), { tenantId, organizationId })\n },\n },\n actions: {\n create: {\n commandId: 'staff.timesheets.time_projects.create',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(staffTimeProjectCreateSchema, raw ?? {}, ctx, translate)\n },\n response: ({ result }) => ({ id: result?.timeProjectId ?? null }),\n status: 201,\n },\n update: {\n commandId: 'staff.timesheets.time_projects.update',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(staffTimeProjectUpdateSchema, raw ?? {}, ctx, translate)\n },\n response: () => ({ ok: true }),\n },\n delete: {\n commandId: 'staff.timesheets.time_projects.delete',\n schema: rawBodySchema,\n mapInput: async ({ parsed, ctx }) => {\n const { translate } = await resolveTranslations()\n const id = resolveCrudRecordId(parsed, ctx, translate)\n return { id }\n },\n response: () => ({ ok: true }),\n },\n },\n})\n\nasync function buildAccessDenialResponse(): Promise<Response> {\n const { translate } = await resolveTranslations()\n return NextResponse.json(\n {\n // Same string a genuinely missing record gets: the body must not tell the\n // caller which of the two it is beyond the membership discriminator.\n error: translate('staff.timesheets.projects.errors.notFound', 'Project not found.'),\n reason: NO_PROJECT_ACCESS_REASON,\n },\n { status: 404 },\n )\n}\n\n/**\n * The list route doubles as the detail read (`?ids=<id>`, and defensively `?id=`),\n * so a caller asking for one specific project needs an answer the UI can turn\n * into screen 17 rather than an indistinguishable empty page. Returning 404 for\n * denied *and* unknown ids alike keeps the endpoint useless for enumeration; the\n * `reason` discriminator carries only \"you are not a member of this project\",\n * never the project or customer it is refusing to describe.\n */\nasync function resolveProjectAccessDenial(req: Request): Promise<Response | null> {\n const url = new URL(req.url)\n const rawIds = url.searchParams.get('ids') ?? url.searchParams.get('id')\n if (!isIdsParamProvided(rawIds)) return null\n const requestedIds = parseIdsParam(rawIds)\n // Malformed ids match nothing on their own; the factory answers an empty list.\n if (requestedIds.length === 0) return null\n\n try {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n // Unauthenticated and unscoped requests are the factory's to reject.\n if (!auth) return null\n const orgScope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const tenantId = orgScope?.tenantId ?? auth.tenantId ?? null\n const organizationId = resolveSingleOrganizationIdOrDeny(orgScope, auth) ?? null\n if (!tenantId || !organizationId) return null\n\n const access = await resolveRequestProjectAccess(req, () =>\n loadProjectAccess(\n container,\n typeof auth.sub === 'string' ? auth.sub : null,\n tenantId,\n organizationId,\n ),\n )\n if (access.canManageAll) return null\n if (requestedIds.some((id) => access.projectIds.includes(id))) return null\n return await buildAccessDenialResponse()\n } catch (err) {\n // Fail closed: an access decision that could not be made is a denial.\n logger.error('staff.timesheets.time-projects denial probe failed', { err })\n return await buildAccessDenialResponse()\n }\n}\n\nexport async function GET(req: Request): Promise<Response> {\n const denial = await resolveProjectAccessDenial(req)\n if (denial) return denial\n return crud.GET(req)\n}\n\nexport const POST = crud.POST\nexport const PUT = crud.PUT\nexport const DELETE = crud.DELETE\n\nexport const timeProjectListItemSchema = z.object({\n id: z.string().uuid().nullable().optional(),\n organization_id: z.string().uuid().nullable().optional(),\n tenant_id: z.string().uuid().nullable().optional(),\n name: z.string().nullable().optional(),\n code: z.string().nullable().optional(),\n description: z.string().nullable().optional(),\n project_type: z.string().nullable().optional(),\n color: z.string().nullable().optional(),\n status: z.string().nullable().optional(),\n customer_id: z.string().uuid().nullable().optional(),\n customer_snapshot: z.record(z.string(), z.unknown()).nullable().optional(),\n owner_user_id: z.string().uuid().nullable().optional(),\n cost_center: z.string().nullable().optional(),\n start_date: z.string().nullable().optional(),\n // numeric(14,4) reaches the client as a decimal string; a number stays accepted\n // for callers that read the column through a numeric-casting driver.\n hourly_rate: z.union([z.string(), z.number()]).nullable().optional(),\n currency_code: z.string().nullable().optional(),\n billable_by_default: z.boolean().nullable().optional(),\n budget_kind: z.string().nullable().optional(),\n budget_value: z.union([z.string(), z.number()]).nullable().optional(),\n budget_warn_at_percent: z.number().int().nullable().optional(),\n default_assignee_staff_member_id: z.string().uuid().nullable().optional(),\n created_at: z.string().nullable().optional(),\n updated_at: z.string().nullable().optional(),\n entryCount: z.number().int().optional(),\n lockedEntryCount: z.number().int().optional(),\n currencyLocked: z.boolean().optional(),\n})\n\nexport const projectAccessDeniedResponseSchema = z.object({\n error: z.string(),\n reason: z.literal(NO_PROJECT_ACCESS_REASON),\n})\n\nconst baseOpenApi = createStaffCrudOpenApi({\n resourceName: 'TimeProject',\n pluralName: 'Time Projects',\n querySchema: listSchema,\n listResponseSchema: createPagedListResponseSchema(timeProjectListItemSchema),\n create: {\n schema: staffTimeProjectCreateSchema,\n description: 'Creates a time project.',\n },\n update: {\n schema: staffTimeProjectUpdateSchema,\n responseSchema: defaultOkResponseSchema,\n description: 'Updates a time project by id.',\n },\n del: {\n schema: z.object({ id: z.string().uuid() }),\n responseSchema: defaultOkResponseSchema,\n description: 'Soft-deletes a time project by id.',\n },\n})\n\nexport const openApi = {\n ...baseOpenApi,\n methods: {\n ...baseOpenApi.methods,\n ...(baseOpenApi.methods?.GET\n ? {\n GET: {\n ...baseOpenApi.methods.GET,\n description: `${baseOpenApi.methods.GET.description ?? ''} Rows are intersected with the caller's project access: without staff.timesheets.projects.manage only projects the caller is an active member of are returned. Asking for a single id the caller may not see answers 404 with reason=${NO_PROJECT_ACCESS_REASON} and never names the project or its customer; the same 404 covers ids that do not exist, so the endpoint cannot be used to enumerate projects.`.trim(),\n responses: [\n ...(baseOpenApi.methods.GET.responses ?? []),\n {\n status: 404,\n description: 'The caller is not a member of the requested project (or it does not exist)',\n schema: projectAccessDeniedResponseSchema,\n },\n ],\n },\n }\n : {}),\n },\n} satisfies typeof baseOpenApi\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { makeCrudRoute, type CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport { staffTimeProjectCrudEvents } from '../../../lib/crud'\nimport { PROJECT_SCOPE_TENANT_HEADER, PROJECT_SCOPE_ORGANIZATION_HEADER, type ResolvedProjectAccessScope } from '../../../lib/time-tracking-ui/projectAccessScopeHeaders'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveCrudRecordId, parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport { isIdsParamProvided, parseIdsParam } from '@open-mercato/shared/lib/crud/ids'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\nimport { StaffTimeProject, StaffTimeProjectMember, StaffTeamMember } from '../../../data/entities'\nimport { staffTimeProjectCreateSchema, staffTimeProjectUpdateSchema } from '../../../data/validators'\nimport { resolveFeatureAccess } from '../../../lib/time-tracking/featureAccess'\nimport { MANAGE_PROJECTS_FEATURE, resolveProjectAccess, type ProjectAccess } from '../../../lib/time-tracking/access'\nimport { readTimeTrackingSettings } from '../../../lib/time-tracking/settings'\nimport { buildSqlInClause } from '../../../lib/time-tracking/sqlInClause'\nimport { sanitizeSearchTerm, parseBooleanFlag } from '../../helpers'\nimport { createStaffCrudOpenApi, createPagedListResponseSchema, defaultOkResponseSchema } from '../../openapi'\nimport { E } from '#generated/entities.ids.generated'\n\nconst logger = createLogger('staff').child({ component: 'api/timesheets/time-projects' })\n\nconst F = {\n id: 'id',\n tenant_id: 'tenant_id',\n organization_id: 'organization_id',\n name: 'name',\n code: 'code',\n description: 'description',\n project_type: 'project_type',\n color: 'color',\n status: 'status',\n customer_id: 'customer_id',\n customer_snapshot: 'customer_snapshot',\n owner_user_id: 'owner_user_id',\n cost_center: 'cost_center',\n start_date: 'start_date',\n hourly_rate: 'hourly_rate',\n currency_code: 'currency_code',\n billable_by_default: 'billable_by_default',\n budget_kind: 'budget_kind',\n budget_value: 'budget_value',\n budget_warn_at_percent: 'budget_warn_at_percent',\n default_assignee_staff_member_id: 'default_assignee_staff_member_id',\n created_at: 'created_at',\n updated_at: 'updated_at',\n deleted_at: 'deleted_at',\n} as const\n\nconst routeMetadata = {\n GET: { requireAuth: true, requireFeatures: ['staff.timesheets.projects.view'] },\n POST: { requireAuth: true, requireFeatures: ['staff.timesheets.projects.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['staff.timesheets.projects.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['staff.timesheets.projects.manage'] },\n}\n\nexport const metadata = routeMetadata\n\nconst rawBodySchema = z.object({}).passthrough()\n\nconst listSchema = z\n .object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n q: z.string().optional(),\n ids: z.string().optional(),\n projectType: z.string().optional(),\n status: z.string().optional(),\n customerId: z.string().uuid().optional(),\n mine: z.string().optional(),\n include: z.string().optional(),\n sortField: z.string().optional(),\n sortDir: z.enum(['asc', 'desc']).optional(),\n })\n .passthrough()\n\n/**\n * Discriminator returned with the 404 a project-scoped read answers when the\n * caller may not see the record. It is the whole body besides a generic error\n * string: naming the project or the customer here would confirm the resource to\n * someone who has no right to it (screen 17 note 1).\n *\n * Denied and genuinely-missing are deliberately indistinguishable for a caller\n * without `staff.timesheets.projects.manage` \u2014 both answer this same 404 \u2014 so the\n * endpoint cannot be used to enumerate which project ids exist. The discriminator\n * only tells the client \"you are not a member\", which is what screen 17 needs to\n * render the request-access affordance; a manager still gets a plain 200-empty\n * for an id that really does not exist.\n */\nexport const NO_PROJECT_ACCESS_REASON = 'no_project_access'\n\n/**\n * Non-UUID sentinel used as the \"match nothing\" id filter. Kept in the shape the\n * `mine=true` branch has always used so both narrowing paths behave identically.\n */\nconst IMPOSSIBLE_PROJECT_ID = '00000000-0000-0000-0000-000000000000'\n\nconst DENIED_ACCESS: ProjectAccess = { canManageAll: false, projectIds: [], staffMemberId: null }\n\ntype RbacServiceLike = {\n getGrantedFeatures?: (\n userId: string,\n options: { tenantId: string | null; organizationId: string | null },\n ) => Promise<string[]>\n userHasAllFeatures?: (\n userId: string,\n required: string[],\n options: { tenantId: string | null; organizationId: string | null },\n ) => Promise<boolean>\n}\n\ntype ContainerLike = { resolve: (name: string) => unknown }\n\n/**\n * Both entry points into a single request \u2014 the denial probe on `GET` and the\n * list narrowing in `buildFilters` \u2014 must agree on what the caller may see, and\n * neither should pay for the other's lookups. The resolved access is therefore\n * memoised per `Request`; the factory hands `buildFilters` the same object the\n * route received.\n */\nconst accessByRequest = new WeakMap<Request, Promise<ProjectAccess>>()\n\nasync function resolveGrantedFeatures(\n container: ContainerLike,\n userId: string,\n scope: { tenantId: string; organizationId: string },\n): Promise<string[]> {\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike | undefined\n if (!rbac?.getGrantedFeatures) return []\n return (await rbac.getGrantedFeatures(userId, scope)) ?? []\n } catch {\n // Fail closed: an unreadable grant set is no grant set, so the caller falls\n // back to their project memberships instead of being treated as a manager.\n return []\n }\n}\n\nasync function resolveUnrestricted(\n container: ContainerLike,\n userId: string,\n scope: { tenantId: string; organizationId: string },\n): Promise<boolean> {\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike | undefined\n if (!rbac?.userHasAllFeatures) return false\n return (await rbac.userHasAllFeatures(userId, [MANAGE_PROJECTS_FEATURE], scope)) === true\n } catch {\n // Fail closed, exactly as the grant lookup does.\n return false\n }\n}\n\nasync function resolveAssignmentGraceDays(\n container: ContainerLike,\n tenantId: string,\n): Promise<number | null> {\n try {\n const configService = container.resolve('moduleConfigService') as ModuleConfigService\n const settings = await readTimeTrackingSettings(configService, { tenantId })\n return settings.access.assignmentGraceDays\n } catch {\n // Fail safe to the documented default rather than widening the window.\n return null\n }\n}\n\nasync function loadProjectAccess(\n container: ContainerLike,\n userId: string | null,\n tenantId: string | null,\n organizationId: string | null,\n): Promise<ProjectAccess> {\n if (!tenantId || !organizationId) return { ...DENIED_ACCESS }\n const scope = { tenantId, organizationId }\n try {\n // One lookup, one authority, and a failure that says so. The two-call version\n // this replaces rescued `canManageAll` but still fed a possibly-empty array\n // into `userFeatures`, which the guards downstream read.\n const access = await resolveFeatureAccess(container, userId, [MANAGE_PROJECTS_FEATURE], scope)\n const em = container.resolve('em') as EntityManager\n return await resolveProjectAccess({\n em: em.fork(),\n userId,\n tenantId,\n organizationId,\n canManageAll: access.allowed,\n userFeatures: access.grantedFeatures,\n assignmentGraceDays: await resolveAssignmentGraceDays(container, tenantId),\n })\n } catch (err) {\n logger.error('staff.timesheets.time-projects access resolution failed', { err })\n return { ...DENIED_ACCESS }\n }\n}\n\nfunction resolveRequestProjectAccess(\n request: Request | undefined,\n load: () => Promise<ProjectAccess>,\n): Promise<ProjectAccess> {\n if (!request) return load()\n const cached = accessByRequest.get(request)\n if (cached) return cached\n const pending = load()\n accessByRequest.set(request, pending)\n return pending\n}\n\nfunction resolveCtxScope(ctx: CrudCtx): { tenantId: string | null; organizationId: string | null } {\n return {\n tenantId: ctx.auth?.tenantId ?? null,\n organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,\n }\n}\n\nasync function resolveListProjectAccess(ctx: CrudCtx): Promise<ProjectAccess> {\n const { tenantId, organizationId } = resolveCtxScope(ctx)\n return resolveRequestProjectAccess(ctx.request, () =>\n loadProjectAccess(ctx.container, ctx.auth?.sub ?? null, tenantId, organizationId),\n )\n}\n\nasync function resolveCallerAssignedProjectIds(\n em: EntityManager,\n userId: string,\n tenantId: string,\n organizationId: string,\n): Promise<string[]> {\n const scopeCtx = { tenantId, organizationId }\n const staffMember = await findOneWithDecryption(\n em.fork(),\n StaffTeamMember,\n { userId, tenantId, organizationId, deletedAt: null },\n {},\n scopeCtx,\n )\n if (!staffMember) return []\n const memberships = await em.fork().find(StaffTimeProjectMember, {\n staffMemberId: staffMember.id,\n tenantId,\n organizationId,\n status: 'active',\n deletedAt: null,\n })\n return Array.from(new Set(memberships.map((m) => m.timeProjectId)))\n}\n\ntype ProjectEntryCountRow = {\n time_project_id: string\n entry_count: string | number\n locked_entry_count: string | number\n}\n\ntype ProjectListItem = {\n id?: unknown\n entryCount?: number\n lockedEntryCount?: number\n currencyLocked?: boolean\n}\n\nfunction toCount(value: string | number | null | undefined): number {\n const parsed = typeof value === 'number' ? value : Number(value ?? 0)\n return Number.isFinite(parsed) ? parsed : 0\n}\n\n/**\n * D-3 read-side signal: currency is read-only once hours have been logged, so the\n * project list/detail response carries the reason alongside the value.\n * `currencyLocked` tells the form to render the field read-only behind the explicit\n * change action, `entryCount` names why, and `lockedEntryCount` says whether even\n * that action will be refused (entries frozen in a closed report).\n *\n * One grouped aggregate for the whole page \u2014 never a per-row count.\n */\nexport async function decorateProjectCurrencyLock(\n items: ProjectListItem[],\n em: EntityManager,\n scope: { tenantId: string; organizationId: string },\n): Promise<void> {\n const ids = Array.from(\n new Set(items.map((item) => item.id).filter((value): value is string => typeof value === 'string' && value.length > 0)),\n )\n if (ids.length === 0) return\n\n const projectIn = buildSqlInClause('time_project_id', ids)\n const rows = (await em.getConnection().execute(\n `\n SELECT time_project_id,\n COUNT(*)::bigint AS entry_count,\n COUNT(locked_report_id)::bigint AS locked_entry_count\n FROM staff_time_entries\n WHERE tenant_id = ?\n AND organization_id = ?\n AND deleted_at IS NULL\n AND ${projectIn.sql}\n GROUP BY time_project_id\n `,\n [scope.tenantId, scope.organizationId, ...projectIn.params],\n )) as ProjectEntryCountRow[]\n\n const counts = new Map(rows.map((row) => [row.time_project_id, row]))\n for (const item of items) {\n const row = typeof item.id === 'string' ? counts.get(item.id) : undefined\n const entryCount = toCount(row?.entry_count)\n item.entryCount = entryCount\n item.lockedEntryCount = toCount(row?.locked_entry_count)\n item.currencyLocked = entryCount > 0\n }\n}\n\n/**\n * The list projection doubles as the detail projection (the edit page re-reads this\n * route with `?id=`), so every column the project form round-trips has to be here.\n */\nexport const timeProjectListFields = [\n F.id,\n F.organization_id,\n F.tenant_id,\n F.name,\n F.code,\n F.description,\n F.project_type,\n F.color,\n F.status,\n F.customer_id,\n F.customer_snapshot,\n F.owner_user_id,\n F.cost_center,\n F.start_date,\n F.hourly_rate,\n F.currency_code,\n F.billable_by_default,\n F.budget_kind,\n F.budget_value,\n F.budget_warn_at_percent,\n F.default_assignee_staff_member_id,\n F.created_at,\n F.updated_at,\n] as const\n\nconst crud = makeCrudRoute({\n metadata: routeMetadata,\n orm: {\n entity: StaffTimeProject,\n idField: 'id',\n orgField: 'organizationId',\n tenantField: 'tenantId',\n softDeleteField: 'deletedAt',\n },\n events: staffTimeProjectCrudEvents,\n indexer: { entityType: 'staff:staff_time_project' },\n enrichers: { entityId: 'staff:staff_time_project' },\n list: {\n schema: listSchema,\n entityId: 'staff:staff_time_project',\n fields: [...timeProjectListFields],\n decorateCustomFields: { entityIds: [E.staff.staff_time_project] },\n sortFieldMap: {\n name: F.name,\n code: F.code,\n status: F.status,\n createdAt: F.created_at,\n updatedAt: F.updated_at,\n startDate: F.start_date,\n },\n buildFilters: async (query, ctx) => {\n const filters: Record<string, unknown> = {}\n let narrowIds: string[] | null = null\n if (typeof query.ids === 'string' && query.ids.trim().length > 0) {\n narrowIds = query.ids\n .split(',')\n .map((value) => value.trim())\n .filter((value) => value.length > 0)\n }\n if (parseBooleanFlag(query.mine) && ctx.auth) {\n const tenantId = ctx.auth.tenantId ?? null\n const organizationId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null\n if (!tenantId || !organizationId) {\n filters[F.id] = { $in: [IMPOSSIBLE_PROJECT_ID] }\n return filters\n }\n const em = ctx.container.resolve('em') as EntityManager\n const assigned = await resolveCallerAssignedProjectIds(\n em,\n ctx.auth.sub,\n tenantId,\n organizationId,\n )\n narrowIds = narrowIds\n ? narrowIds.filter((id) => assigned.includes(id))\n : assigned\n if (narrowIds.length === 0) {\n filters[F.id] = { $in: [IMPOSSIBLE_PROJECT_ID] }\n return filters\n }\n }\n // Every project-scoped read filters through the one resolver: a caller\n // without `staff.timesheets.projects.manage` only ever sees the projects\n // they are an active member of. A non-member gets no row at all \u2014 not a\n // redacted one \u2014 so the customer and project name never leave the server.\n const access = await resolveListProjectAccess(ctx)\n if (!access.canManageAll) {\n narrowIds = narrowIds\n ? narrowIds.filter((id) => access.projectIds.includes(id))\n : [...access.projectIds]\n if (narrowIds.length === 0) {\n filters[F.id] = { $in: [IMPOSSIBLE_PROJECT_ID] }\n return filters\n }\n }\n if (narrowIds && narrowIds.length > 0) {\n filters[F.id] = { $in: narrowIds }\n }\n const term = sanitizeSearchTerm(query.q)\n if (term) {\n filters[F.name] = { $ilike: `%${escapeLikePattern(term)}%` }\n }\n if (typeof query.projectType === 'string' && query.projectType.trim().length > 0) {\n filters[F.project_type] = query.projectType.trim()\n }\n if (typeof query.status === 'string' && query.status.trim().length > 0) {\n filters[F.status] = query.status.trim()\n }\n if (typeof query.customerId === 'string' && query.customerId.trim().length > 0) {\n filters[F.customer_id] = query.customerId.trim()\n }\n return filters\n },\n },\n hooks: {\n afterList: async (res, ctx) => {\n const items = Array.isArray((res as { items?: unknown })?.items)\n ? ((res as { items: ProjectListItem[] }).items)\n : []\n if (items.length === 0) return\n const tenantId = ctx.auth?.tenantId ?? null\n const organizationId = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n if (!tenantId || !organizationId) return\n const em = ctx.container.resolve('em') as EntityManager\n await decorateProjectCurrencyLock(items, em.fork(), { tenantId, organizationId })\n },\n },\n actions: {\n create: {\n commandId: 'staff.timesheets.time_projects.create',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(staffTimeProjectCreateSchema, raw ?? {}, ctx, translate)\n },\n response: ({ result }) => ({ id: result?.timeProjectId ?? null }),\n status: 201,\n },\n update: {\n commandId: 'staff.timesheets.time_projects.update',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(staffTimeProjectUpdateSchema, raw ?? {}, ctx, translate)\n },\n response: () => ({ ok: true }),\n },\n delete: {\n commandId: 'staff.timesheets.time_projects.delete',\n schema: rawBodySchema,\n mapInput: async ({ parsed, ctx }) => {\n const { translate } = await resolveTranslations()\n const id = resolveCrudRecordId(parsed, ctx, translate)\n return { id }\n },\n response: () => ({ ok: true }),\n },\n },\n})\n\nasync function buildAccessDenialResponse(): Promise<Response> {\n const { translate } = await resolveTranslations()\n return NextResponse.json(\n {\n // Same string a genuinely missing record gets: the body must not tell the\n // caller which of the two it is beyond the membership discriminator.\n error: translate('staff.timesheets.projects.errors.notFound', 'Project not found.'),\n reason: NO_PROJECT_ACCESS_REASON,\n },\n { status: 404 },\n )\n}\n\n/**\n * The list route doubles as the detail read (`?ids=<id>`, and defensively `?id=`),\n * so a caller asking for one specific project needs an answer the UI can turn\n * into screen 17 rather than an indistinguishable empty page. Returning 404 for\n * denied *and* unknown ids alike keeps the endpoint useless for enumeration; the\n * `reason` discriminator carries only \"you are not a member of this project\",\n * never the project or customer it is refusing to describe.\n */\nasync function resolveProjectAccessDenial(req: Request): Promise<{ denial: Response | null; scope: ResolvedProjectAccessScope | null }> {\n const unresolved = { denial: null, scope: null }\n const url = new URL(req.url)\n const rawIds = url.searchParams.get('ids') ?? url.searchParams.get('id')\n if (!isIdsParamProvided(rawIds)) return unresolved\n const requestedIds = parseIdsParam(rawIds)\n // Malformed ids match nothing on their own; the factory answers an empty list.\n if (requestedIds.length === 0) return unresolved\n\n try {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n // Unauthenticated and unscoped requests are the factory's to reject.\n if (!auth) return unresolved\n const orgScope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const tenantId = orgScope?.tenantId ?? auth.tenantId ?? null\n const organizationId = resolveSingleOrganizationIdOrDeny(orgScope, auth) ?? null\n if (!tenantId || !organizationId) return unresolved\n\n const scope = orgScope?.tenantId && orgScope.selectedId && orgScope.selectionRejected !== true\n ? { tenantId: orgScope.tenantId, organizationId: orgScope.selectedId }\n : null\n const access = await resolveRequestProjectAccess(req, () =>\n loadProjectAccess(\n container,\n typeof auth.sub === 'string' ? auth.sub : null,\n tenantId,\n organizationId,\n ),\n )\n if (access.canManageAll) return { denial: null, scope }\n if (requestedIds.some((id) => access.projectIds.includes(id))) return { denial: null, scope }\n return { denial: await buildAccessDenialResponse(), scope }\n } catch (err) {\n // Fail closed: an access decision that could not be made is a denial.\n logger.error('staff.timesheets.time-projects denial probe failed', { err })\n return { denial: await buildAccessDenialResponse(), scope: null }\n }\n}\n\nexport async function GET(req: Request): Promise<Response> {\n const { denial, scope } = await resolveProjectAccessDenial(req)\n const response = denial ?? await crud.GET(req)\n if (scope) {\n response.headers.set(PROJECT_SCOPE_TENANT_HEADER, scope.tenantId)\n response.headers.set(PROJECT_SCOPE_ORGANIZATION_HEADER, scope.organizationId)\n }\n return response\n}\n\nexport const POST = crud.POST\nexport const PUT = crud.PUT\nexport const DELETE = crud.DELETE\n\nexport const timeProjectListItemSchema = z.object({\n id: z.string().uuid().nullable().optional(),\n organization_id: z.string().uuid().nullable().optional(),\n tenant_id: z.string().uuid().nullable().optional(),\n name: z.string().nullable().optional(),\n code: z.string().nullable().optional(),\n description: z.string().nullable().optional(),\n project_type: z.string().nullable().optional(),\n color: z.string().nullable().optional(),\n status: z.string().nullable().optional(),\n customer_id: z.string().uuid().nullable().optional(),\n customer_snapshot: z.record(z.string(), z.unknown()).nullable().optional(),\n owner_user_id: z.string().uuid().nullable().optional(),\n cost_center: z.string().nullable().optional(),\n start_date: z.string().nullable().optional(),\n // numeric(14,4) reaches the client as a decimal string; a number stays accepted\n // for callers that read the column through a numeric-casting driver.\n hourly_rate: z.union([z.string(), z.number()]).nullable().optional(),\n currency_code: z.string().nullable().optional(),\n billable_by_default: z.boolean().nullable().optional(),\n budget_kind: z.string().nullable().optional(),\n budget_value: z.union([z.string(), z.number()]).nullable().optional(),\n budget_warn_at_percent: z.number().int().nullable().optional(),\n default_assignee_staff_member_id: z.string().uuid().nullable().optional(),\n created_at: z.string().nullable().optional(),\n updated_at: z.string().nullable().optional(),\n entryCount: z.number().int().optional(),\n lockedEntryCount: z.number().int().optional(),\n currencyLocked: z.boolean().optional(),\n})\n\nexport const projectAccessDeniedResponseSchema = z.object({\n error: z.string(),\n reason: z.literal(NO_PROJECT_ACCESS_REASON),\n})\n\nconst baseOpenApi = createStaffCrudOpenApi({\n resourceName: 'TimeProject',\n pluralName: 'Time Projects',\n querySchema: listSchema,\n listResponseSchema: createPagedListResponseSchema(timeProjectListItemSchema),\n create: {\n schema: staffTimeProjectCreateSchema,\n description: 'Creates a time project.',\n },\n update: {\n schema: staffTimeProjectUpdateSchema,\n responseSchema: defaultOkResponseSchema,\n description: 'Updates a time project by id.',\n },\n del: {\n schema: z.object({ id: z.string().uuid() }),\n responseSchema: defaultOkResponseSchema,\n description: 'Soft-deletes a time project by id.',\n },\n})\n\nexport const openApi = {\n ...baseOpenApi,\n methods: {\n ...baseOpenApi.methods,\n ...(baseOpenApi.methods?.GET\n ? {\n GET: {\n ...baseOpenApi.methods.GET,\n description: `${baseOpenApi.methods.GET.description ?? ''} Project-specific reads include optional x-om-project-scope-tenant-id and x-om-project-scope-organization-id response headers when the server resolves a scope. These identify the request scope for client hydration; a missing header provides no scope identity. Rows are intersected with the caller's project access: without staff.timesheets.projects.manage only projects the caller is an active member of are returned. Asking for a single id the caller may not see answers 404 with reason=${NO_PROJECT_ACCESS_REASON} and never names the project or its customer; the same 404 covers ids that do not exist, so the endpoint cannot be used to enumerate projects.`.trim(),\n responses: [\n ...(baseOpenApi.methods.GET.responses ?? []),\n {\n status: 404,\n description: 'The caller is not a member of the requested project (or it does not exist)',\n schema: projectAccessDeniedResponseSchema,\n },\n ],\n },\n }\n : {}),\n },\n} satisfies typeof baseOpenApi\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -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) {