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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/cart/api/current/lines/bulk/route.js +1 -1
  3. package/dist/modules/cart/api/current/lines/bulk/route.js.map +2 -2
  4. package/dist/modules/cart/api/current/lines/route.js +1 -1
  5. package/dist/modules/cart/api/current/lines/route.js.map +2 -2
  6. package/dist/modules/cart/commands/lines.js +5 -10
  7. package/dist/modules/cart/commands/lines.js.map +2 -2
  8. package/dist/modules/cart/lib/currentCartRoute.js +8 -0
  9. package/dist/modules/cart/lib/currentCartRoute.js.map +3 -3
  10. package/dist/modules/cart/lib/lineMutationLock.js +52 -0
  11. package/dist/modules/cart/lib/lineMutationLock.js.map +7 -0
  12. package/dist/modules/customer_accounts/api/admin/roles.js +25 -2
  13. package/dist/modules/customer_accounts/api/admin/roles.js.map +2 -2
  14. package/dist/modules/customer_accounts/api/admin/users.js +24 -7
  15. package/dist/modules/customer_accounts/api/admin/users.js.map +2 -2
  16. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +77 -15
  17. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
  18. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js +12 -1
  19. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js.map +2 -2
  20. package/dist/modules/customer_accounts/data/validators.js +2 -1
  21. package/dist/modules/customer_accounts/data/validators.js.map +2 -2
  22. package/dist/modules/customer_accounts/lib/adminOrganizationScope.js +46 -0
  23. package/dist/modules/customer_accounts/lib/adminOrganizationScope.js.map +7 -0
  24. package/dist/modules/customers/api/customer-dictionary-entries/route.js +86 -0
  25. package/dist/modules/customers/api/customer-dictionary-entries/route.js.map +7 -0
  26. package/dist/modules/customers/api/dictionaries/[kind]/route.js +29 -7
  27. package/dist/modules/customers/api/dictionaries/[kind]/route.js.map +2 -2
  28. package/dist/modules/customers/cli.js +28 -110
  29. package/dist/modules/customers/cli.js.map +3 -3
  30. package/dist/modules/customers/components/DictionarySettings.js +1 -1
  31. package/dist/modules/customers/components/DictionarySettings.js.map +1 -1
  32. package/dist/modules/customers/components/detail/ManageTagsDialog.js +1 -1
  33. package/dist/modules/customers/components/detail/ManageTagsDialog.js.map +2 -2
  34. package/dist/modules/customers/components/detail/TaskDialog.js +14 -1
  35. package/dist/modules/customers/components/detail/TaskDialog.js.map +2 -2
  36. package/dist/modules/customers/components/detail/TaskForm.js +1 -16
  37. package/dist/modules/customers/components/detail/TaskForm.js.map +2 -2
  38. package/dist/modules/customers/components/detail/TasksSection.js +38 -10
  39. package/dist/modules/customers/components/detail/TasksSection.js.map +2 -2
  40. package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js +12 -5
  41. package/dist/modules/customers/components/detail/hooks/useCustomerDictionary.js.map +2 -2
  42. package/dist/modules/customers/components/detail/hooks/useInteractions.js +3 -13
  43. package/dist/modules/customers/components/detail/hooks/useInteractions.js.map +2 -2
  44. package/dist/modules/customers/lib/dictionaryDefaults.js +134 -0
  45. package/dist/modules/customers/lib/dictionaryDefaults.js.map +7 -0
  46. package/dist/modules/customers/lib/dictionaryLabels.js +59 -0
  47. package/dist/modules/customers/lib/dictionaryLabels.js.map +7 -0
  48. package/dist/modules/customers/lib/interactionCompatibility.js +1 -0
  49. package/dist/modules/customers/lib/interactionCompatibility.js.map +2 -2
  50. package/dist/modules/customers/translations.js +9 -0
  51. package/dist/modules/customers/translations.js.map +7 -0
  52. package/dist/modules/eudr/index.js +13 -0
  53. package/dist/modules/eudr/index.js.map +2 -2
  54. package/dist/modules/sales/commands/documents.js +3 -3
  55. package/dist/modules/sales/commands/documents.js.map +2 -2
  56. package/dist/modules/sales/components/documents/ItemsSection.js +75 -21
  57. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  58. package/dist/modules/sales/components/documents/lineItemUtils.js +2 -2
  59. package/dist/modules/sales/components/documents/lineItemUtils.js.map +2 -2
  60. package/dist/modules/staff/api/timesheets/access-requests/route.js +11 -5
  61. package/dist/modules/staff/api/timesheets/access-requests/route.js.map +2 -2
  62. package/dist/modules/staff/api/timesheets/time-projects/route.js +19 -12
  63. package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
  64. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js +21 -12
  65. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js.map +2 -2
  66. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +14 -9
  67. package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +2 -2
  68. package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js +31 -10
  69. package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js.map +2 -2
  70. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js +64 -0
  71. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScope.js.map +7 -0
  72. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js +13 -0
  73. package/dist/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.js.map +7 -0
  74. package/dist/modules/sync_excel/index.js +11 -1
  75. package/dist/modules/sync_excel/index.js.map +2 -2
  76. package/dist/modules/translations/di.js +2 -0
  77. package/dist/modules/translations/di.js.map +2 -2
  78. package/package.json +7 -7
  79. package/src/modules/cart/api/current/lines/bulk/route.ts +1 -1
  80. package/src/modules/cart/api/current/lines/route.ts +1 -1
  81. package/src/modules/cart/commands/lines.ts +5 -10
  82. package/src/modules/cart/i18n/de.json +1 -0
  83. package/src/modules/cart/i18n/en.json +1 -0
  84. package/src/modules/cart/i18n/es.json +1 -0
  85. package/src/modules/cart/i18n/ko.json +1 -0
  86. package/src/modules/cart/i18n/pl.json +1 -0
  87. package/src/modules/cart/lib/currentCartRoute.ts +8 -0
  88. package/src/modules/cart/lib/lineMutationLock.ts +59 -0
  89. package/src/modules/customer_accounts/api/admin/roles.ts +26 -2
  90. package/src/modules/customer_accounts/api/admin/users.ts +26 -7
  91. package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +96 -13
  92. package/src/modules/customer_accounts/backend/customer_accounts/users/page.tsx +9 -1
  93. package/src/modules/customer_accounts/data/validators.ts +1 -0
  94. package/src/modules/customer_accounts/i18n/de.json +10 -0
  95. package/src/modules/customer_accounts/i18n/en.json +10 -0
  96. package/src/modules/customer_accounts/i18n/es.json +10 -0
  97. package/src/modules/customer_accounts/i18n/ko.json +10 -0
  98. package/src/modules/customer_accounts/i18n/pl.json +10 -0
  99. package/src/modules/customer_accounts/lib/adminOrganizationScope.ts +71 -0
  100. package/src/modules/customers/api/customer-dictionary-entries/route.ts +82 -0
  101. package/src/modules/customers/api/dictionaries/[kind]/route.ts +29 -6
  102. package/src/modules/customers/cli.ts +23 -124
  103. package/src/modules/customers/components/DictionarySettings.tsx +1 -1
  104. package/src/modules/customers/components/detail/ManageTagsDialog.tsx +1 -1
  105. package/src/modules/customers/components/detail/TaskDialog.tsx +15 -1
  106. package/src/modules/customers/components/detail/TaskForm.tsx +1 -17
  107. package/src/modules/customers/components/detail/TasksSection.tsx +44 -14
  108. package/src/modules/customers/components/detail/hooks/useCustomerDictionary.ts +14 -8
  109. package/src/modules/customers/components/detail/hooks/useInteractions.ts +3 -4
  110. package/src/modules/customers/i18n/de.json +81 -0
  111. package/src/modules/customers/i18n/en.json +81 -0
  112. package/src/modules/customers/i18n/es.json +81 -0
  113. package/src/modules/customers/i18n/ko.json +81 -0
  114. package/src/modules/customers/i18n/pl.json +81 -0
  115. package/src/modules/customers/lib/dictionaryDefaults.ts +137 -0
  116. package/src/modules/customers/lib/dictionaryLabels.ts +73 -0
  117. package/src/modules/customers/lib/interactionCompatibility.ts +1 -0
  118. package/src/modules/customers/translations.ts +5 -0
  119. package/src/modules/eudr/index.ts +13 -0
  120. package/src/modules/sales/commands/documents.ts +3 -3
  121. package/src/modules/sales/components/documents/ItemsSection.tsx +82 -23
  122. package/src/modules/sales/components/documents/lineItemUtils.ts +2 -2
  123. package/src/modules/sales/i18n/de.json +1 -0
  124. package/src/modules/sales/i18n/en.json +1 -0
  125. package/src/modules/sales/i18n/es.json +1 -0
  126. package/src/modules/sales/i18n/ko.json +1 -0
  127. package/src/modules/sales/i18n/pl.json +1 -0
  128. package/src/modules/staff/api/timesheets/access-requests/route.ts +13 -14
  129. package/src/modules/staff/api/timesheets/time-projects/route.ts +22 -13
  130. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.tsx +25 -14
  131. package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +14 -13
  132. package/src/modules/staff/lib/time-tracking-ui/NoProjectAccess.tsx +30 -8
  133. package/src/modules/staff/lib/time-tracking-ui/projectAccessScope.ts +102 -0
  134. package/src/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.ts +15 -0
  135. package/src/modules/sync_excel/index.ts +10 -0
  136. package/src/modules/translations/di.ts +2 -0
@@ -9,32 +9,50 @@ import { flash } from "@open-mercato/ui/backend/FlashMessages";
9
9
  import { apiCallOrThrow } from "@open-mercato/ui/backend/utils/apiCall";
10
10
  import { useGuardedMutation } from "@open-mercato/ui/backend/injection/useGuardedMutation";
11
11
  import { useT } from "@open-mercato/shared/lib/i18n/context";
12
+ import { useProjectAccessScope } from "./projectAccessScope.js";
12
13
  const MY_WORK_HREF = "/backend/staff/time-tracking";
13
14
  const ACCESS_REQUEST_ENDPOINT = "/api/staff/timesheets/access-requests";
14
15
  const ACCESS_REQUEST_CONTEXT_ID = "staff.time_tracking.access_request";
15
- function AccessRequestButton({
16
+ function AccessRequestButton(props) {
17
+ const { scopeKey, isCurrentScope } = useProjectAccessScope(props.timeProjectId, props.resolvedScope);
18
+ return /* @__PURE__ */ jsx(ScopedAccessRequestButton, { ...props, isCurrentScope }, scopeKey);
19
+ }
20
+ function ScopedAccessRequestButton({
16
21
  timeProjectId,
17
22
  label,
18
23
  sentLabel,
19
- variant = "outline"
24
+ variant = "outline",
25
+ isCurrentScope
20
26
  }) {
21
27
  const t = useT();
22
28
  const [submitting, setSubmitting] = React.useState(false);
23
29
  const [sent, setSent] = React.useState(false);
30
+ const mounted = React.useRef(true);
31
+ React.useEffect(() => {
32
+ mounted.current = true;
33
+ return () => {
34
+ mounted.current = false;
35
+ };
36
+ }, []);
24
37
  const { runMutation, retryLastMutation } = useGuardedMutation({
25
38
  contextId: ACCESS_REQUEST_CONTEXT_ID,
26
39
  blockedMessage: t("ui.forms.flash.saveBlocked", "Save blocked by validation")
27
40
  });
28
41
  const requestAccess = React.useCallback(async () => {
42
+ const isCurrent = () => mounted.current && isCurrentScope();
43
+ if (!isCurrent()) return;
29
44
  const payload = timeProjectId ? { timeProjectId } : {};
30
45
  setSubmitting(true);
31
46
  try {
32
47
  await runMutation({
33
- operation: () => apiCallOrThrow(ACCESS_REQUEST_ENDPOINT, {
34
- method: "POST",
35
- headers: { "content-type": "application/json" },
36
- body: JSON.stringify(payload)
37
- }),
48
+ operation: () => {
49
+ if (!isCurrent()) throw new Error("[internal] Access request scope changed");
50
+ return apiCallOrThrow(ACCESS_REQUEST_ENDPOINT, {
51
+ method: "POST",
52
+ headers: { "content-type": "application/json" },
53
+ body: JSON.stringify(payload)
54
+ });
55
+ },
38
56
  context: {
39
57
  formId: ACCESS_REQUEST_CONTEXT_ID,
40
58
  resourceKind: "staff.timesheets.access_request",
@@ -42,6 +60,7 @@ function AccessRequestButton({
42
60
  },
43
61
  mutationPayload: payload
44
62
  });
63
+ if (!isCurrent()) return;
45
64
  setSent(true);
46
65
  flash(
47
66
  t(
@@ -51,20 +70,21 @@ function AccessRequestButton({
51
70
  "success"
52
71
  );
53
72
  } catch {
73
+ if (!isCurrent()) return;
54
74
  flash(
55
75
  t("staff.time_tracking.access.request.error", "Could not send the access request."),
56
76
  "error"
57
77
  );
58
78
  } finally {
59
- setSubmitting(false);
79
+ if (isCurrent()) setSubmitting(false);
60
80
  }
61
- }, [retryLastMutation, runMutation, t, timeProjectId]);
81
+ }, [isCurrentScope, retryLastMutation, runMutation, t, timeProjectId]);
62
82
  return /* @__PURE__ */ jsxs(Button, { type: "button", variant, onClick: requestAccess, disabled: submitting || sent, children: [
63
83
  /* @__PURE__ */ jsx(MessageSquare, { className: "size-4", "aria-hidden": "true" }),
64
84
  sent ? sentLabel : label
65
85
  ] });
66
86
  }
67
- function NoProjectAccess({ timeProjectId, myWorkHref = MY_WORK_HREF }) {
87
+ function NoProjectAccess({ timeProjectId, resolvedScope, myWorkHref = MY_WORK_HREF }) {
68
88
  const t = useT();
69
89
  return /* @__PURE__ */ jsx(Page, { children: /* @__PURE__ */ jsx(PageBody, { children: /* @__PURE__ */ jsx(
70
90
  EmptyState,
@@ -86,6 +106,7 @@ function NoProjectAccess({ timeProjectId, myWorkHref = MY_WORK_HREF }) {
86
106
  AccessRequestButton,
87
107
  {
88
108
  timeProjectId,
109
+ resolvedScope,
89
110
  label: t("staff.time_tracking.access.denied.request", "Request access"),
90
111
  sentLabel: t("staff.time_tracking.access.request.sent", "Request sent")
91
112
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/staff/lib/time-tracking-ui/NoProjectAccess.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { ArrowLeft, FolderOpen, MessageSquare, ShieldAlert } from 'lucide-react'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { EmptyState } from '@open-mercato/ui/primitives/empty-state'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport const MY_WORK_HREF = '/backend/staff/time-tracking'\nexport const ACCESS_REQUEST_ENDPOINT = '/api/staff/timesheets/access-requests'\nconst ACCESS_REQUEST_CONTEXT_ID = 'staff.time_tracking.access_request'\n\nexport type AccessRequestButtonProps = {\n /**\n * Sent with the request so a Team Leader lands on the right project team\n * drawer. It is never rendered \u2014 screen 17 note 1 forbids confirming that the\n * project exists to a user who has no right to it.\n */\n timeProjectId?: string | null\n label: string\n sentLabel: string\n variant?: 'default' | 'outline'\n}\n\nexport function AccessRequestButton({\n timeProjectId,\n label,\n sentLabel,\n variant = 'outline',\n}: AccessRequestButtonProps) {\n const t = useT()\n const [submitting, setSubmitting] = React.useState(false)\n const [sent, setSent] = React.useState(false)\n const { runMutation, retryLastMutation } = useGuardedMutation<Record<string, unknown>>({\n contextId: ACCESS_REQUEST_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n const requestAccess = React.useCallback(async () => {\n const payload = timeProjectId ? { timeProjectId } : {}\n setSubmitting(true)\n try {\n await runMutation({\n operation: () =>\n apiCallOrThrow(ACCESS_REQUEST_ENDPOINT, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(payload),\n }),\n context: {\n formId: ACCESS_REQUEST_CONTEXT_ID,\n resourceKind: 'staff.timesheets.access_request',\n retryLastMutation,\n },\n mutationPayload: payload,\n })\n setSent(true)\n flash(\n t(\n 'staff.time_tracking.access.request.success',\n 'Your request has been sent to the Team Leaders.',\n ),\n 'success',\n )\n } catch {\n flash(\n t('staff.time_tracking.access.request.error', 'Could not send the access request.'),\n 'error',\n )\n } finally {\n setSubmitting(false)\n }\n }, [retryLastMutation, runMutation, t, timeProjectId])\n\n return (\n <Button type=\"button\" variant={variant} onClick={requestAccess} disabled={submitting || sent}>\n <MessageSquare className=\"size-4\" aria-hidden=\"true\" />\n {sent ? sentLabel : label}\n </Button>\n )\n}\n\nexport type NoProjectAccessProps = {\n timeProjectId?: string | null\n /** Where the \"Back to My work\" action returns to. */\n myWorkHref?: string\n}\n\n/**\n * Screen 17 \u2014 a project-scoped route the caller may not open. The copy names\n * neither the customer nor the project (note 1) and covers both \"never had\n * access\" and \"access was revoked\" (note 2); navigation stays untouched because\n * the caller still has other projects (note 3).\n */\nexport function NoProjectAccess({ timeProjectId, myWorkHref = MY_WORK_HREF }: NoProjectAccessProps) {\n const t = useT()\n return (\n <Page>\n <PageBody>\n <EmptyState\n variant=\"subtle\"\n size=\"lg\"\n icon={<ShieldAlert className=\"size-6\" aria-hidden=\"true\" />}\n title={t('staff.time_tracking.access.denied.title', 'You do not have access to this project')}\n description={t(\n 'staff.time_tracking.access.denied.description',\n 'Project access is granted one project at a time by a Team Leader. If you had access before, it may have been revoked \u2014 your earlier time entries stay saved.',\n )}\n actions={\n <div className=\"flex flex-wrap items-center justify-center gap-2\">\n <Button type=\"button\" variant=\"default\" asChild>\n <a href={myWorkHref}>\n <ArrowLeft className=\"size-4\" aria-hidden=\"true\" />\n {t('staff.time_tracking.access.denied.back', 'Back to My work')}\n </a>\n </Button>\n <AccessRequestButton\n timeProjectId={timeProjectId}\n label={t('staff.time_tracking.access.denied.request', 'Request access')}\n sentLabel={t('staff.time_tracking.access.request.sent', 'Request sent')}\n />\n </div>\n }\n />\n </PageBody>\n </Page>\n )\n}\n\n/**\n * Screen 2 \u2014 the caller has no project assignments at all. Rendered inside the\n * host page (which keeps its own header), so it stays a bare empty state.\n */\nexport function NoTimeProjectAssignments() {\n const t = useT()\n return (\n <EmptyState\n variant=\"subtle\"\n size=\"lg\"\n icon={<FolderOpen className=\"size-6\" aria-hidden=\"true\" />}\n title={t('staff.time_tracking.access.empty.title', 'You have no projects assigned yet')}\n description={t(\n 'staff.time_tracking.access.empty.description',\n 'A Team Leader grants project access. Once you are added to a project it shows up here right away \u2014 no need to log in again.',\n )}\n actions={\n <AccessRequestButton\n variant=\"default\"\n label={t('staff.time_tracking.access.empty.request', 'Ask a Team Leader for access')}\n sentLabel={t('staff.time_tracking.access.request.sent', 'Request sent')}\n />\n }\n />\n )\n}\n\nexport default NoProjectAccess\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { ArrowLeft, FolderOpen, MessageSquare, ShieldAlert } from 'lucide-react'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { EmptyState } from '@open-mercato/ui/primitives/empty-state'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useProjectAccessScope } from './projectAccessScope'\nimport type { ResolvedProjectAccessScope } from './projectAccessScopeHeaders'\n\nexport const MY_WORK_HREF = '/backend/staff/time-tracking'\nexport const ACCESS_REQUEST_ENDPOINT = '/api/staff/timesheets/access-requests'\nconst ACCESS_REQUEST_CONTEXT_ID = 'staff.time_tracking.access_request'\n\nexport type AccessRequestButtonProps = {\n /**\n * Sent with the request so a Team Leader lands on the right project team\n * drawer. It is never rendered \u2014 screen 17 note 1 forbids confirming that the\n * project exists to a user who has no right to it.\n */\n timeProjectId?: string | null\n resolvedScope?: ResolvedProjectAccessScope | null\n label: string\n sentLabel: string\n variant?: 'default' | 'outline'\n}\n\nexport function AccessRequestButton(props: AccessRequestButtonProps) {\n const { scopeKey, isCurrentScope } = useProjectAccessScope(props.timeProjectId, props.resolvedScope)\n return <ScopedAccessRequestButton key={scopeKey} {...props} isCurrentScope={isCurrentScope} />\n}\n\nfunction ScopedAccessRequestButton({\n timeProjectId,\n label,\n sentLabel,\n variant = 'outline',\n isCurrentScope,\n}: AccessRequestButtonProps & { isCurrentScope: () => boolean }) {\n const t = useT()\n const [submitting, setSubmitting] = React.useState(false)\n const [sent, setSent] = React.useState(false)\n const mounted = React.useRef(true)\n React.useEffect(() => {\n mounted.current = true\n return () => { mounted.current = false }\n }, [])\n const { runMutation, retryLastMutation } = useGuardedMutation<Record<string, unknown>>({\n contextId: ACCESS_REQUEST_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n const requestAccess = React.useCallback(async () => {\n const isCurrent = () => mounted.current && isCurrentScope()\n if (!isCurrent()) return\n const payload = timeProjectId ? { timeProjectId } : {}\n setSubmitting(true)\n try {\n await runMutation({\n operation: () => {\n if (!isCurrent()) throw new Error('[internal] Access request scope changed')\n return apiCallOrThrow(ACCESS_REQUEST_ENDPOINT, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(payload),\n })\n },\n context: {\n formId: ACCESS_REQUEST_CONTEXT_ID,\n resourceKind: 'staff.timesheets.access_request',\n retryLastMutation,\n },\n mutationPayload: payload,\n })\n if (!isCurrent()) return\n setSent(true)\n flash(\n t(\n 'staff.time_tracking.access.request.success',\n 'Your request has been sent to the Team Leaders.',\n ),\n 'success',\n )\n } catch {\n if (!isCurrent()) return\n flash(\n t('staff.time_tracking.access.request.error', 'Could not send the access request.'),\n 'error',\n )\n } finally {\n if (isCurrent()) setSubmitting(false)\n }\n }, [isCurrentScope, retryLastMutation, runMutation, t, timeProjectId])\n\n return (\n <Button type=\"button\" variant={variant} onClick={requestAccess} disabled={submitting || sent}>\n <MessageSquare className=\"size-4\" aria-hidden=\"true\" />\n {sent ? sentLabel : label}\n </Button>\n )\n}\n\nexport type NoProjectAccessProps = {\n timeProjectId?: string | null\n resolvedScope?: ResolvedProjectAccessScope | null\n /** Where the \"Back to My work\" action returns to. */\n myWorkHref?: string\n}\n\n/**\n * Screen 17 \u2014 a project-scoped route the caller may not open. The copy names\n * neither the customer nor the project (note 1) and covers both \"never had\n * access\" and \"access was revoked\" (note 2); navigation stays untouched because\n * the caller still has other projects (note 3).\n */\nexport function NoProjectAccess({ timeProjectId, resolvedScope, myWorkHref = MY_WORK_HREF }: NoProjectAccessProps) {\n const t = useT()\n return (\n <Page>\n <PageBody>\n <EmptyState\n variant=\"subtle\"\n size=\"lg\"\n icon={<ShieldAlert className=\"size-6\" aria-hidden=\"true\" />}\n title={t('staff.time_tracking.access.denied.title', 'You do not have access to this project')}\n description={t(\n 'staff.time_tracking.access.denied.description',\n 'Project access is granted one project at a time by a Team Leader. If you had access before, it may have been revoked \u2014 your earlier time entries stay saved.',\n )}\n actions={\n <div className=\"flex flex-wrap items-center justify-center gap-2\">\n <Button type=\"button\" variant=\"default\" asChild>\n <a href={myWorkHref}>\n <ArrowLeft className=\"size-4\" aria-hidden=\"true\" />\n {t('staff.time_tracking.access.denied.back', 'Back to My work')}\n </a>\n </Button>\n <AccessRequestButton\n timeProjectId={timeProjectId}\n resolvedScope={resolvedScope}\n label={t('staff.time_tracking.access.denied.request', 'Request access')}\n sentLabel={t('staff.time_tracking.access.request.sent', 'Request sent')}\n />\n </div>\n }\n />\n </PageBody>\n </Page>\n )\n}\n\n/**\n * Screen 2 \u2014 the caller has no project assignments at all. Rendered inside the\n * host page (which keeps its own header), so it stays a bare empty state.\n */\nexport function NoTimeProjectAssignments() {\n const t = useT()\n return (\n <EmptyState\n variant=\"subtle\"\n size=\"lg\"\n icon={<FolderOpen className=\"size-6\" aria-hidden=\"true\" />}\n title={t('staff.time_tracking.access.empty.title', 'You have no projects assigned yet')}\n description={t(\n 'staff.time_tracking.access.empty.description',\n 'A Team Leader grants project access. Once you are added to a project it shows up here right away \u2014 no need to log in again.',\n )}\n actions={\n <AccessRequestButton\n variant=\"default\"\n label={t('staff.time_tracking.access.empty.request', 'Ask a Team Leader for access')}\n sentLabel={t('staff.time_tracking.access.request.sent', 'Request sent')}\n />\n }\n />\n )\n}\n\nexport default NoProjectAccess\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,64 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ import { useOrganizationScopeVersion } from "@open-mercato/shared/lib/frontend/useOrganizationScope";
4
+ import {
5
+ parseSelectedOrganizationCookie,
6
+ parseSelectedTenantCookie
7
+ } from "@open-mercato/core/modules/directory/utils/scopeCookies";
8
+ function readProjectAccessScope(timeProjectId) {
9
+ const cookieHeader = typeof document === "undefined" ? null : document.cookie;
10
+ return {
11
+ projectId: timeProjectId ?? null,
12
+ tenantId: parseSelectedTenantCookie(cookieHeader),
13
+ organizationId: parseSelectedOrganizationCookie(cookieHeader)
14
+ };
15
+ }
16
+ function bindProjectAccessScope(bound, current, resolvedScope) {
17
+ const continues = (boundValue, currentValue, resolvedValue) => boundValue === currentValue || boundValue === null && currentValue === resolvedValue;
18
+ if (bound.projectId !== current.projectId) return null;
19
+ if (!continues(bound.tenantId, current.tenantId, resolvedScope?.tenantId)) return null;
20
+ if (!continues(bound.organizationId, current.organizationId, resolvedScope?.organizationId)) return null;
21
+ return current;
22
+ }
23
+ function isSameProjectAccessScope(left, right) {
24
+ return left.projectId === right.projectId && left.tenantId === right.tenantId && left.organizationId === right.organizationId;
25
+ }
26
+ function rebindProjectAccessScope(binding, current) {
27
+ const continued = bindProjectAccessScope(binding.scope, current, binding.resolvedScope);
28
+ if (!continued) return { scope: current, generation: binding.generation + 1, resolvedScope: null };
29
+ if (isSameProjectAccessScope(continued, binding.scope)) return binding;
30
+ return { ...binding, scope: continued };
31
+ }
32
+ function useProjectAccessScope(timeProjectId, initialResolvedScope) {
33
+ useOrganizationScopeVersion();
34
+ const current = readProjectAccessScope(timeProjectId);
35
+ const [binding, setBinding] = React.useState(() => ({
36
+ scope: current,
37
+ generation: 0,
38
+ resolvedScope: initialResolvedScope ?? null
39
+ }));
40
+ const nextBinding = rebindProjectAccessScope(binding, current);
41
+ if (nextBinding !== binding) setBinding(nextBinding);
42
+ const boundScope = React.useRef(nextBinding);
43
+ boundScope.current = nextBinding;
44
+ const generation = nextBinding.generation;
45
+ const isCurrentScope = React.useCallback(() => {
46
+ const active = boundScope.current;
47
+ return active.generation === generation && bindProjectAccessScope(active.scope, readProjectAccessScope(timeProjectId), active.resolvedScope) !== null;
48
+ }, [generation, timeProjectId]);
49
+ const resolveScope = React.useCallback((resolvedScope) => {
50
+ setBinding((previous) => {
51
+ if (previous.generation !== generation) return previous;
52
+ if (!bindProjectAccessScope(previous.scope, readProjectAccessScope(timeProjectId), resolvedScope)) return previous;
53
+ const changed = previous.resolvedScope !== null && resolvedScope !== null && (previous.resolvedScope.tenantId !== resolvedScope.tenantId || previous.resolvedScope.organizationId !== resolvedScope.organizationId);
54
+ return { ...previous, resolvedScope, generation: previous.generation + (changed ? 1 : 0) };
55
+ });
56
+ }, [generation, timeProjectId]);
57
+ return { scopeKey: String(generation), isCurrentScope, resolvedScope: nextBinding.resolvedScope, resolveScope };
58
+ }
59
+ export {
60
+ bindProjectAccessScope,
61
+ readProjectAccessScope,
62
+ useProjectAccessScope
63
+ };
64
+ //# sourceMappingURL=projectAccessScope.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../src/modules/staff/lib/time-tracking-ui/projectAccessScope.ts"],
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport {\n parseSelectedOrganizationCookie,\n parseSelectedTenantCookie,\n} from '@open-mercato/core/modules/directory/utils/scopeCookies'\nimport type { ResolvedProjectAccessScope } from './projectAccessScopeHeaders'\n\nexport type ProjectAccessScope = {\n projectId: string | null\n tenantId: string | null\n organizationId: string | null\n}\n\nexport type ProjectAccessScopeState = {\n /** Stable while the scope stays the same; changes when the project, tenant or organization changes. */\n scopeKey: string\n /** Reads the scope cookies again, so it also catches a change that has not been rendered yet. */\n isCurrentScope: () => boolean\n resolvedScope: ResolvedProjectAccessScope | null\n resolveScope: (scope: ResolvedProjectAccessScope | null) => void\n}\n\ntype ProjectAccessScopeBinding = {\n scope: ProjectAccessScope\n generation: number\n resolvedScope: ResolvedProjectAccessScope | null\n}\n\nexport function readProjectAccessScope(timeProjectId?: string | null): ProjectAccessScope {\n const cookieHeader = typeof document === 'undefined' ? null : document.cookie\n return {\n projectId: timeProjectId ?? null,\n tenantId: parseSelectedTenantCookie(cookieHeader),\n organizationId: parseSelectedOrganizationCookie(cookieHeader),\n }\n}\n\nexport function bindProjectAccessScope(\n bound: ProjectAccessScope,\n current: ProjectAccessScope,\n resolvedScope?: ResolvedProjectAccessScope | null,\n): ProjectAccessScope | null {\n const continues = (boundValue: string | null, currentValue: string | null, resolvedValue?: string) =>\n boundValue === currentValue || (boundValue === null && currentValue === resolvedValue)\n if (bound.projectId !== current.projectId) return null\n if (!continues(bound.tenantId, current.tenantId, resolvedScope?.tenantId)) return null\n if (!continues(bound.organizationId, current.organizationId, resolvedScope?.organizationId)) return null\n return current\n}\n\nfunction isSameProjectAccessScope(left: ProjectAccessScope, right: ProjectAccessScope): boolean {\n return left.projectId === right.projectId\n && left.tenantId === right.tenantId\n && left.organizationId === right.organizationId\n}\n\nfunction rebindProjectAccessScope(\n binding: ProjectAccessScopeBinding,\n current: ProjectAccessScope,\n): ProjectAccessScopeBinding {\n const continued = bindProjectAccessScope(binding.scope, current, binding.resolvedScope)\n if (!continued) return { scope: current, generation: binding.generation + 1, resolvedScope: null }\n if (isSameProjectAccessScope(continued, binding.scope)) return binding\n return { ...binding, scope: continued }\n}\n\nexport function useProjectAccessScope(\n timeProjectId?: string | null,\n initialResolvedScope?: ResolvedProjectAccessScope | null,\n): ProjectAccessScopeState {\n useOrganizationScopeVersion()\n const current = readProjectAccessScope(timeProjectId)\n const [binding, setBinding] = React.useState<ProjectAccessScopeBinding>(() => ({\n scope: current,\n generation: 0,\n resolvedScope: initialResolvedScope ?? null,\n }))\n const nextBinding = rebindProjectAccessScope(binding, current)\n if (nextBinding !== binding) setBinding(nextBinding)\n const boundScope = React.useRef(nextBinding)\n boundScope.current = nextBinding\n const generation = nextBinding.generation\n const isCurrentScope = React.useCallback(() => {\n const active = boundScope.current\n return active.generation === generation\n && bindProjectAccessScope(active.scope, readProjectAccessScope(timeProjectId), active.resolvedScope) !== null\n }, [generation, timeProjectId])\n const resolveScope = React.useCallback((resolvedScope: ResolvedProjectAccessScope | null) => {\n setBinding((previous) => {\n if (previous.generation !== generation) return previous\n if (!bindProjectAccessScope(previous.scope, readProjectAccessScope(timeProjectId), resolvedScope)) return previous\n const changed = previous.resolvedScope !== null && resolvedScope !== null\n && (previous.resolvedScope.tenantId !== resolvedScope.tenantId\n || previous.resolvedScope.organizationId !== resolvedScope.organizationId)\n return { ...previous, resolvedScope, generation: previous.generation + (changed ? 1 : 0) }\n })\n }, [generation, timeProjectId])\n return { scopeKey: String(generation), isCurrentScope, resolvedScope: nextBinding.resolvedScope, resolveScope }\n}\n"],
5
+ "mappings": ";AAEA,YAAY,WAAW;AACvB,SAAS,mCAAmC;AAC5C;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAwBA,SAAS,uBAAuB,eAAmD;AACxF,QAAM,eAAe,OAAO,aAAa,cAAc,OAAO,SAAS;AACvE,SAAO;AAAA,IACL,WAAW,iBAAiB;AAAA,IAC5B,UAAU,0BAA0B,YAAY;AAAA,IAChD,gBAAgB,gCAAgC,YAAY;AAAA,EAC9D;AACF;AAEO,SAAS,uBACd,OACA,SACA,eAC2B;AAC3B,QAAM,YAAY,CAAC,YAA2B,cAA6B,kBACzE,eAAe,gBAAiB,eAAe,QAAQ,iBAAiB;AAC1E,MAAI,MAAM,cAAc,QAAQ,UAAW,QAAO;AAClD,MAAI,CAAC,UAAU,MAAM,UAAU,QAAQ,UAAU,eAAe,QAAQ,EAAG,QAAO;AAClF,MAAI,CAAC,UAAU,MAAM,gBAAgB,QAAQ,gBAAgB,eAAe,cAAc,EAAG,QAAO;AACpG,SAAO;AACT;AAEA,SAAS,yBAAyB,MAA0B,OAAoC;AAC9F,SAAO,KAAK,cAAc,MAAM,aAC3B,KAAK,aAAa,MAAM,YACxB,KAAK,mBAAmB,MAAM;AACrC;AAEA,SAAS,yBACP,SACA,SAC2B;AAC3B,QAAM,YAAY,uBAAuB,QAAQ,OAAO,SAAS,QAAQ,aAAa;AACtF,MAAI,CAAC,UAAW,QAAO,EAAE,OAAO,SAAS,YAAY,QAAQ,aAAa,GAAG,eAAe,KAAK;AACjG,MAAI,yBAAyB,WAAW,QAAQ,KAAK,EAAG,QAAO;AAC/D,SAAO,EAAE,GAAG,SAAS,OAAO,UAAU;AACxC;AAEO,SAAS,sBACd,eACA,sBACyB;AACzB,8BAA4B;AAC5B,QAAM,UAAU,uBAAuB,aAAa;AACpD,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAoC,OAAO;AAAA,IAC7E,OAAO;AAAA,IACP,YAAY;AAAA,IACZ,eAAe,wBAAwB;AAAA,EACzC,EAAE;AACF,QAAM,cAAc,yBAAyB,SAAS,OAAO;AAC7D,MAAI,gBAAgB,QAAS,YAAW,WAAW;AACnD,QAAM,aAAa,MAAM,OAAO,WAAW;AAC3C,aAAW,UAAU;AACrB,QAAM,aAAa,YAAY;AAC/B,QAAM,iBAAiB,MAAM,YAAY,MAAM;AAC7C,UAAM,SAAS,WAAW;AAC1B,WAAO,OAAO,eAAe,cACxB,uBAAuB,OAAO,OAAO,uBAAuB,aAAa,GAAG,OAAO,aAAa,MAAM;AAAA,EAC7G,GAAG,CAAC,YAAY,aAAa,CAAC;AAC9B,QAAM,eAAe,MAAM,YAAY,CAAC,kBAAqD;AAC3F,eAAW,CAAC,aAAa;AACvB,UAAI,SAAS,eAAe,WAAY,QAAO;AAC/C,UAAI,CAAC,uBAAuB,SAAS,OAAO,uBAAuB,aAAa,GAAG,aAAa,EAAG,QAAO;AAC1G,YAAM,UAAU,SAAS,kBAAkB,QAAQ,kBAAkB,SAC/D,SAAS,cAAc,aAAa,cAAc,YACjD,SAAS,cAAc,mBAAmB,cAAc;AAC/D,aAAO,EAAE,GAAG,UAAU,eAAe,YAAY,SAAS,cAAc,UAAU,IAAI,GAAG;AAAA,IAC3F,CAAC;AAAA,EACH,GAAG,CAAC,YAAY,aAAa,CAAC;AAC9B,SAAO,EAAE,UAAU,OAAO,UAAU,GAAG,gBAAgB,eAAe,YAAY,eAAe,aAAa;AAChH;",
6
+ "names": []
7
+ }
@@ -0,0 +1,13 @@
1
+ const PROJECT_SCOPE_TENANT_HEADER = "x-om-project-scope-tenant-id";
2
+ const PROJECT_SCOPE_ORGANIZATION_HEADER = "x-om-project-scope-organization-id";
3
+ function readResolvedProjectAccessScope(response) {
4
+ const tenantId = response.headers?.get(PROJECT_SCOPE_TENANT_HEADER);
5
+ const organizationId = response.headers?.get(PROJECT_SCOPE_ORGANIZATION_HEADER);
6
+ return tenantId && organizationId ? { tenantId, organizationId } : null;
7
+ }
8
+ export {
9
+ PROJECT_SCOPE_ORGANIZATION_HEADER,
10
+ PROJECT_SCOPE_TENANT_HEADER,
11
+ readResolvedProjectAccessScope
12
+ };
13
+ //# sourceMappingURL=projectAccessScopeHeaders.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../src/modules/staff/lib/time-tracking-ui/projectAccessScopeHeaders.ts"],
4
+ "sourcesContent": ["export const PROJECT_SCOPE_TENANT_HEADER = 'x-om-project-scope-tenant-id'\nexport const PROJECT_SCOPE_ORGANIZATION_HEADER = 'x-om-project-scope-organization-id'\n\nexport type ResolvedProjectAccessScope = {\n tenantId: string\n organizationId: string\n}\n\nexport function readResolvedProjectAccessScope(\n response: { headers?: Pick<Headers, 'get'> },\n): ResolvedProjectAccessScope | null {\n const tenantId = response.headers?.get(PROJECT_SCOPE_TENANT_HEADER)\n const organizationId = response.headers?.get(PROJECT_SCOPE_ORGANIZATION_HEADER)\n return tenantId && organizationId ? { tenantId, organizationId } : null\n}\n"],
5
+ "mappings": "AAAO,MAAM,8BAA8B;AACpC,MAAM,oCAAoC;AAO1C,SAAS,+BACd,UACmC;AACnC,QAAM,WAAW,SAAS,SAAS,IAAI,2BAA2B;AAClE,QAAM,iBAAiB,SAAS,SAAS,IAAI,iCAAiC;AAC9E,SAAO,YAAY,iBAAiB,EAAE,UAAU,eAAe,IAAI;AACrE;",
6
+ "names": []
7
+ }
@@ -1,7 +1,17 @@
1
1
  const metadata = {
2
2
  id: "sync_excel",
3
3
  title: "Excel / CSV Import",
4
- description: "File-upload-based CSV import foundation built on top of the data sync hub."
4
+ description: "File-upload-based CSV import foundation built on top of the data sync hub.",
5
+ /**
6
+ * `customers` is a HARD dependency: `lib/adapters/customers.ts` reads `E.customers.*` at module scope.
7
+ *
8
+ * `E` only carries a key for an ENABLED module, so with `customers` switched off that read is
9
+ * `undefined.customer_entity` and it throws while the adapter is being imported — not inside a request, where
10
+ * it could be traced. Declaring it has the generator refuse the combination before anybody starts the app.
11
+ *
12
+ * `data_sync` is the hub this is built on, named in the description and now in the manifest too.
13
+ */
14
+ requires: ["customers", "data_sync"]
5
15
  };
6
16
  var sync_excel_default = metadata;
7
17
  export {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/sync_excel/index.ts"],
4
- "sourcesContent": ["export const metadata = {\n id: 'sync_excel',\n title: 'Excel / CSV Import',\n description: 'File-upload-based CSV import foundation built on top of the data sync hub.',\n}\n\nexport default metadata\n"],
5
- "mappings": "AAAO,MAAM,WAAW;AAAA,EACtB,IAAI;AAAA,EACJ,OAAO;AAAA,EACP,aAAa;AACf;AAEA,IAAO,qBAAQ;",
4
+ "sourcesContent": ["export const metadata = {\n id: 'sync_excel',\n title: 'Excel / CSV Import',\n description: 'File-upload-based CSV import foundation built on top of the data sync hub.',\n /**\n * `customers` is a HARD dependency: `lib/adapters/customers.ts` reads `E.customers.*` at module scope.\n *\n * `E` only carries a key for an ENABLED module, so with `customers` switched off that read is\n * `undefined.customer_entity` and it throws while the adapter is being imported \u2014 not inside a request, where\n * it could be traced. Declaring it has the generator refuse the combination before anybody starts the app.\n *\n * `data_sync` is the hub this is built on, named in the description and now in the manifest too.\n */\n requires: ['customers', 'data_sync'],\n}\n\nexport default metadata\n"],
5
+ "mappings": "AAAO,MAAM,WAAW;AAAA,EACtB,IAAI;AAAA,EACJ,OAAO;AAAA,EACP,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUb,UAAU,CAAC,aAAa,WAAW;AACrC;AAEA,IAAO,qBAAQ;",
6
6
  "names": []
7
7
  }
@@ -2,6 +2,7 @@ import { registerTranslatableFields } from "@open-mercato/shared/lib/localizatio
2
2
  import { registerTranslationOverlayPlugin } from "@open-mercato/shared/lib/localization/overlay-plugin";
3
3
  import { registerSupportedLocalesResolver } from "@open-mercato/shared/lib/i18n/locale-registry";
4
4
  import { translatableFields as catalogFields } from "../catalog/translations.js";
5
+ import { translatableFields as customerFields } from "../customers/translations.js";
5
6
  import { translatableFields as dictionaryFields } from "../dictionaries/translations.js";
6
7
  import { translatableFields as entitiesFields } from "../entities/translations.js";
7
8
  import { translatableFields as resourcesFields } from "../resources/translations.js";
@@ -11,6 +12,7 @@ import { resolveTenantSupportedLocales } from "./lib/supported-locales.js";
11
12
  registerSupportedLocalesResolver(resolveTenantSupportedLocales);
12
13
  function register() {
13
14
  registerTranslatableFields(catalogFields);
15
+ registerTranslatableFields(customerFields);
14
16
  registerTranslatableFields(dictionaryFields);
15
17
  registerTranslatableFields(entitiesFields);
16
18
  registerTranslatableFields(resourcesFields);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/translations/di.ts"],
4
- "sourcesContent": ["import { registerTranslatableFields } from '@open-mercato/shared/lib/localization/translatable-fields'\nimport { registerTranslationOverlayPlugin } from '@open-mercato/shared/lib/localization/overlay-plugin'\nimport { registerSupportedLocalesResolver } from '@open-mercato/shared/lib/i18n/locale-registry'\nimport { translatableFields as catalogFields } from '../catalog/translations'\nimport { translatableFields as dictionaryFields } from '../dictionaries/translations'\nimport { translatableFields as entitiesFields } from '../entities/translations'\nimport { translatableFields as resourcesFields } from '../resources/translations'\nimport { applyTranslationOverlays } from './lib/apply'\nimport { resolveLocaleFromRequest } from './lib/locale'\nimport { resolveTenantSupportedLocales } from './lib/supported-locales'\n\n// Registered at module scope, not inside `register()`: module DI registrars only\n// run when the first request container is built, and the root layout resolves the\n// served locale set without ever building one. Inside `register()` the very first\n// render of a fresh process would find an empty resolver slot and serve the\n// un-narrowed set. `di.generated.ts` imports this module statically from the app\n// bootstrap, so the slot is filled at import time instead.\nregisterSupportedLocalesResolver(resolveTenantSupportedLocales)\n\nexport function register() {\n registerTranslatableFields(catalogFields)\n registerTranslatableFields(dictionaryFields)\n registerTranslatableFields(entitiesFields)\n registerTranslatableFields(resourcesFields)\n registerTranslationOverlayPlugin(applyTranslationOverlays, resolveLocaleFromRequest)\n}\n"],
5
- "mappings": "AAAA,SAAS,kCAAkC;AAC3C,SAAS,wCAAwC;AACjD,SAAS,wCAAwC;AACjD,SAAS,sBAAsB,qBAAqB;AACpD,SAAS,sBAAsB,wBAAwB;AACvD,SAAS,sBAAsB,sBAAsB;AACrD,SAAS,sBAAsB,uBAAuB;AACtD,SAAS,gCAAgC;AACzC,SAAS,gCAAgC;AACzC,SAAS,qCAAqC;AAQ9C,iCAAiC,6BAA6B;AAEvD,SAAS,WAAW;AACzB,6BAA2B,aAAa;AACxC,6BAA2B,gBAAgB;AAC3C,6BAA2B,cAAc;AACzC,6BAA2B,eAAe;AAC1C,mCAAiC,0BAA0B,wBAAwB;AACrF;",
4
+ "sourcesContent": ["import { registerTranslatableFields } from '@open-mercato/shared/lib/localization/translatable-fields'\nimport { registerTranslationOverlayPlugin } from '@open-mercato/shared/lib/localization/overlay-plugin'\nimport { registerSupportedLocalesResolver } from '@open-mercato/shared/lib/i18n/locale-registry'\nimport { translatableFields as catalogFields } from '../catalog/translations'\nimport { translatableFields as customerFields } from '../customers/translations'\nimport { translatableFields as dictionaryFields } from '../dictionaries/translations'\nimport { translatableFields as entitiesFields } from '../entities/translations'\nimport { translatableFields as resourcesFields } from '../resources/translations'\nimport { applyTranslationOverlays } from './lib/apply'\nimport { resolveLocaleFromRequest } from './lib/locale'\nimport { resolveTenantSupportedLocales } from './lib/supported-locales'\n\n// Registered at module scope, not inside `register()`: module DI registrars only\n// run when the first request container is built, and the root layout resolves the\n// served locale set without ever building one. Inside `register()` the very first\n// render of a fresh process would find an empty resolver slot and serve the\n// un-narrowed set. `di.generated.ts` imports this module statically from the app\n// bootstrap, so the slot is filled at import time instead.\nregisterSupportedLocalesResolver(resolveTenantSupportedLocales)\n\nexport function register() {\n registerTranslatableFields(catalogFields)\n registerTranslatableFields(customerFields)\n registerTranslatableFields(dictionaryFields)\n registerTranslatableFields(entitiesFields)\n registerTranslatableFields(resourcesFields)\n registerTranslationOverlayPlugin(applyTranslationOverlays, resolveLocaleFromRequest)\n}\n"],
5
+ "mappings": "AAAA,SAAS,kCAAkC;AAC3C,SAAS,wCAAwC;AACjD,SAAS,wCAAwC;AACjD,SAAS,sBAAsB,qBAAqB;AACpD,SAAS,sBAAsB,sBAAsB;AACrD,SAAS,sBAAsB,wBAAwB;AACvD,SAAS,sBAAsB,sBAAsB;AACrD,SAAS,sBAAsB,uBAAuB;AACtD,SAAS,gCAAgC;AACzC,SAAS,gCAAgC;AACzC,SAAS,qCAAqC;AAQ9C,iCAAiC,6BAA6B;AAEvD,SAAS,WAAW;AACzB,6BAA2B,aAAa;AACxC,6BAA2B,cAAc;AACzC,6BAA2B,gBAAgB;AAC3C,6BAA2B,cAAc;AACzC,6BAA2B,eAAe;AAC1C,mCAAiC,0BAA0B,wBAAwB;AACrF;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.10.1-develop.7833.1.d6cbf0e3ab",
3
+ "version": "0.10.1-develop.7841.1.af87ac614d",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -266,16 +266,16 @@
266
266
  "zod": "^4.6.6"
267
267
  },
268
268
  "peerDependencies": {
269
- "@open-mercato/ai-assistant": "0.10.1-develop.7833.1.d6cbf0e3ab",
270
- "@open-mercato/shared": "0.10.1-develop.7833.1.d6cbf0e3ab",
271
- "@open-mercato/ui": "0.10.1-develop.7833.1.d6cbf0e3ab",
269
+ "@open-mercato/ai-assistant": "0.10.1-develop.7841.1.af87ac614d",
270
+ "@open-mercato/shared": "0.10.1-develop.7841.1.af87ac614d",
271
+ "@open-mercato/ui": "0.10.1-develop.7841.1.af87ac614d",
272
272
  "react": "^19.0.0",
273
273
  "react-dom": "^19.0.0"
274
274
  },
275
275
  "devDependencies": {
276
- "@open-mercato/ai-assistant": "0.10.1-develop.7833.1.d6cbf0e3ab",
277
- "@open-mercato/shared": "0.10.1-develop.7833.1.d6cbf0e3ab",
278
- "@open-mercato/ui": "0.10.1-develop.7833.1.d6cbf0e3ab",
276
+ "@open-mercato/ai-assistant": "0.10.1-develop.7841.1.af87ac614d",
277
+ "@open-mercato/shared": "0.10.1-develop.7841.1.af87ac614d",
278
+ "@open-mercato/ui": "0.10.1-develop.7841.1.af87ac614d",
279
279
  "@testing-library/dom": "^10.4.1",
280
280
  "@testing-library/jest-dom": "^7.0.1",
281
281
  "@testing-library/react": "^16.3.3",
@@ -184,7 +184,7 @@ export const openApi: OpenApiRouteDoc = {
184
184
  { status: 409, description: 'Optimistic-lock conflict' },
185
185
  { status: 422, description: 'Idempotency key mismatch' },
186
186
  { status: 429, description: 'Rate limited' },
187
- { status: 503, description: 'No store context resolvable for this request (§6a.1a)' },
187
+ { status: 503, description: 'No store context resolvable, or cart_busy after 15 seconds waiting for the line lock. The requested line add/bulk-add phases did not run; earlier activity/context/repricing may have persisted. Retry the same request after Retry-After: 1 second, preserving Idempotency-Key.' },
188
188
  ],
189
189
  },
190
190
  },
@@ -201,7 +201,7 @@ export const openApi: OpenApiRouteDoc = {
201
201
  { status: 409, description: 'Optimistic-lock conflict, or the cart is a `proposal` cart' },
202
202
  { status: 422, description: 'Quantity rule violation, idempotency key mismatch, or an unpriceable product' },
203
203
  { status: 429, description: 'Rate limited' },
204
- { status: 503, description: 'No store context resolvable for this request (§6a.1a)' },
204
+ { status: 503, description: 'No store context resolvable, or cart_busy after 15 seconds waiting for the line lock. The requested line add/bulk-add phases did not run; earlier activity/context/repricing may have persisted. Retry the same request after Retry-After: 1 second, preserving Idempotency-Key.' },
205
205
  ],
206
206
  },
207
207
  },
@@ -57,6 +57,7 @@ import {
57
57
  } from '../lib/visibility'
58
58
  import type { CartVisibilityLineRef, ResolvedAssortmentScope } from '../lib/visibility'
59
59
  import { shouldEnforceAssortmentScope } from '../lib/routeContext'
60
+ import { withCartLineMutationLock } from '../lib/lineMutationLock'
60
61
  import type { ResolvedCartRouteContext } from '../lib/routeContext'
61
62
  import { buildRecomputeCartTotalsPhase } from '../lib/reprice'
62
63
  import type { CartPriceChange } from '../lib/reprice'
@@ -938,10 +939,7 @@ const addLineCommand: CommandHandler<CartLinesAddInput, CartLinesAddOutcome> = {
938
939
  // `cart.lines.add`/`.bulkAdd` call against this cart instead, closing the race
939
940
  // `TC-CART-CONC-05` reproduced (a silently lost update, and a raw 500 off
940
941
  // `cart_lines_unique_line_key`).
941
- await withAtomicFlush(em, [
942
- async () => {
943
- await em.execute('select pg_advisory_xact_lock(hashtextextended(?::text, 0))', [`cart:lines:${cart.id}`])
944
- },
942
+ await withCartLineMutationLock(em, cart.id, [
945
943
  async () => {
946
944
  const existing = await findExistingCustomerLine(em, scope, cart.id, input.productId, variantId, input.configuration ?? null)
947
945
  const pricingContext = buildCartPricingRowFilterContext(cart)
@@ -1049,7 +1047,7 @@ const addLineCommand: CommandHandler<CartLinesAddInput, CartLinesAddOutcome> = {
1049
1047
  if (!shouldRecompute()) return
1050
1048
  await phase()
1051
1049
  }),
1052
- ], { transaction: true, label: 'cart.lines.add' })
1050
+ ], 'cart.lines.add')
1053
1051
 
1054
1052
  if (!outcome) throw new Error('[internal] cart.lines.add produced no outcome')
1055
1053
  // Captured into plain `const`s (never reassigned themselves) rather than read directly
@@ -1641,10 +1639,7 @@ const bulkAddLinesCommand: CommandHandler<CartLinesBulkAddInput, CartLinesBulkAd
1641
1639
  // decision is read-decide-write against rows that may not exist yet, so two concurrent
1642
1640
  // `bulkAdd`/`add` calls on this cart must serialize, not just this call's own entries
1643
1641
  // against each other.
1644
- await withAtomicFlush(em, [
1645
- async () => {
1646
- await em.execute('select pg_advisory_xact_lock(hashtextextended(?::text, 0))', [`cart:lines:${cart.id}`])
1647
- },
1642
+ await withCartLineMutationLock(em, cart.id, [
1648
1643
  ...phases,
1649
1644
  ...buildCartPromotionsAndTotalsPhases({
1650
1645
  em,
@@ -1657,7 +1652,7 @@ const bulkAddLinesCommand: CommandHandler<CartLinesBulkAddInput, CartLinesBulkAd
1657
1652
  removedGiftLines,
1658
1653
  promotionWarnings,
1659
1654
  }),
1660
- ], { transaction: true, label: 'cart.lines.bulkAdd' })
1655
+ ], 'cart.lines.bulkAdd')
1661
1656
  await emitVisibilityRejectedEvent(cart, visibilityRejectedCount, 'lines.bulkAdd')
1662
1657
 
1663
1658
  const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
@@ -4,6 +4,7 @@
4
4
  "cart.audit.lines.bulkAdd": "Add cart lines (bulk)",
5
5
  "cart.audit.lines.remove": "Remove cart line",
6
6
  "cart.audit.merge": "Merge cart",
7
+ "cart.errors.busy": "Der Warenkorb ist ausgelastet. Bitte versuche es erneut.",
7
8
  "cart.errors.store_context_unavailable": "Cart is unavailable for this store right now",
8
9
  "cart.errors.unexpected": "Unexpected error",
9
10
  "cart.warnings.code_no_longer_valid": "A previously applied code is no longer valid",
@@ -4,6 +4,7 @@
4
4
  "cart.audit.lines.bulkAdd": "Add cart lines (bulk)",
5
5
  "cart.audit.lines.remove": "Remove cart line",
6
6
  "cart.audit.merge": "Merge cart",
7
+ "cart.errors.busy": "Cart is busy. Please try again.",
7
8
  "cart.errors.store_context_unavailable": "Cart is unavailable for this store right now",
8
9
  "cart.errors.unexpected": "Unexpected error",
9
10
  "cart.warnings.code_no_longer_valid": "A previously applied code is no longer valid",
@@ -4,6 +4,7 @@
4
4
  "cart.audit.lines.bulkAdd": "Add cart lines (bulk)",
5
5
  "cart.audit.lines.remove": "Remove cart line",
6
6
  "cart.audit.merge": "Merge cart",
7
+ "cart.errors.busy": "El carrito está ocupado. Inténtalo de nuevo.",
7
8
  "cart.errors.store_context_unavailable": "Cart is unavailable for this store right now",
8
9
  "cart.errors.unexpected": "Unexpected error",
9
10
  "cart.warnings.code_no_longer_valid": "A previously applied code is no longer valid",
@@ -4,6 +4,7 @@
4
4
  "cart.audit.lines.bulkAdd": "Add cart lines (bulk)",
5
5
  "cart.audit.lines.remove": "Remove cart line",
6
6
  "cart.audit.merge": "Merge cart",
7
+ "cart.errors.busy": "장바구니가 사용 중입니다. 다시 시도해 주세요.",
7
8
  "cart.errors.store_context_unavailable": "Cart is unavailable for this store right now",
8
9
  "cart.errors.unexpected": "Unexpected error",
9
10
  "cart.warnings.code_no_longer_valid": "A previously applied code is no longer valid",
@@ -4,6 +4,7 @@
4
4
  "cart.audit.lines.bulkAdd": "Add cart lines (bulk)",
5
5
  "cart.audit.lines.remove": "Remove cart line",
6
6
  "cart.audit.merge": "Merge cart",
7
+ "cart.errors.busy": "Koszyk jest zajęty. Spróbuj ponownie.",
7
8
  "cart.errors.store_context_unavailable": "Cart is unavailable for this store right now",
8
9
  "cart.errors.unexpected": "Unexpected error",
9
10
  "cart.warnings.code_no_longer_valid": "A previously applied code is no longer valid",
@@ -10,6 +10,7 @@ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
10
10
  import { getCartCommand } from '../commands/cart'
11
11
  import type { CartGetByTokenInput, CartSummary } from '../commands/cart'
12
12
  import type { CartPriceChange } from './reprice'
13
+ import { isCartLineMutationLockTimeoutError } from './lineMutationLock'
13
14
  import { isCartStoreContextUnavailableError, resolveCartRouteContext } from './routeContext'
14
15
  import type { ResolveCartRouteContextOptions, ResolvedCartRouteContext } from './routeContext'
15
16
 
@@ -173,6 +174,13 @@ const logger = createLogger('cart').child({ component: 'api' })
173
174
  * unexpected 500, logged and reported.
174
175
  */
175
176
  export async function mapRouteErrorToResponse(error: unknown): Promise<NextResponse> {
177
+ if (isCartLineMutationLockTimeoutError(error)) {
178
+ const { translate } = await resolveTranslations()
179
+ return NextResponse.json(
180
+ { error: translate('cart.errors.busy', 'Cart is busy. Please try again.'), code: 'cart_busy' },
181
+ { status: 503, headers: { 'Retry-After': '1', 'Cache-Control': 'no-store' } },
182
+ )
183
+ }
176
184
  if (isCrudHttpError(error)) return NextResponse.json(error.body, { status: error.status })
177
185
  const interceptorRejection = getCommandInterceptorHttpRejection(error)
178
186
  if (interceptorRejection) return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
@@ -0,0 +1,59 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
3
+ import { calculateBackoffDelayMs } from '@open-mercato/shared/lib/delivery/retry'
4
+
5
+ const CART_LINE_LOCK_TIMEOUT = Symbol.for('@open-mercato/cart:LineMutationLockTimeout')
6
+
7
+ export class CartLineMutationLockTimeoutError extends Error {
8
+ readonly [CART_LINE_LOCK_TIMEOUT] = true
9
+
10
+ constructor() {
11
+ super('[internal] Cart line mutation lock wait deadline exceeded')
12
+ this.name = 'CartLineMutationLockTimeoutError'
13
+ }
14
+ }
15
+
16
+ export function isCartLineMutationLockTimeoutError(error: unknown): error is CartLineMutationLockTimeoutError {
17
+ return typeof error === 'object' && error !== null && Reflect.get(error, CART_LINE_LOCK_TIMEOUT) === true
18
+ }
19
+
20
+ export async function withCartLineMutationLock(
21
+ em: EntityManager,
22
+ cartId: string,
23
+ phases: Array<() => void | Promise<void>>,
24
+ label: string,
25
+ waitDeadlineMs = 15_000,
26
+ ): Promise<void> {
27
+ const key = `cart:lines:${cartId}`
28
+ const deadline = Date.now() + waitDeadlineMs
29
+ const busy = new Error('[internal] Cart line mutation lock is busy')
30
+ const joinsTransaction = em.isInTransaction()
31
+
32
+ for (let attempt = 1; ; attempt += 1) {
33
+ try {
34
+ await withAtomicFlush(em, [
35
+ async () => {
36
+ if (joinsTransaction) {
37
+ await em.execute('select pg_advisory_xact_lock(hashtextextended(?::text, 0))', [key])
38
+ return
39
+ }
40
+ const rows = await em.execute<Array<{ locked: boolean }>>(
41
+ 'select pg_try_advisory_xact_lock(hashtextextended(?::text, 0)) as locked',
42
+ [key],
43
+ )
44
+ if (!rows[0]?.locked) throw busy
45
+ },
46
+ ...phases,
47
+ ], { transaction: true, label })
48
+ return
49
+ } catch (error) {
50
+ if (error !== busy || em.isInTransaction()) throw error
51
+ }
52
+
53
+ const remainingMs = deadline - Date.now()
54
+ if (remainingMs <= 0) throw new CartLineMutationLockTimeoutError()
55
+ const delayMs = calculateBackoffDelayMs(Math.min(attempt, 5), { baseDelayMs: 50, maxJitterMs: 25 })
56
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delayMs, remainingMs)))
57
+ if (Date.now() >= deadline) throw new CartLineMutationLockTimeoutError()
58
+ }
59
+ }