@open-mercato/core 0.6.6-develop.6229.1.ebe6706732 → 0.6.6-develop.6245.1.2be3b151f8

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 (137) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/commands/users.js +1 -0
  3. package/dist/modules/auth/commands/users.js.map +2 -2
  4. package/dist/modules/communication_channels/data/enrichers.js +9 -1
  5. package/dist/modules/communication_channels/data/enrichers.js.map +2 -2
  6. package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.js +3 -5
  7. package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.js.map +2 -2
  8. package/dist/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.js.map +2 -2
  9. package/dist/modules/customers/api/pipeline-stages/reorder/route.js +39 -1
  10. package/dist/modules/customers/api/pipeline-stages/reorder/route.js.map +2 -2
  11. package/dist/modules/customers/api/pipeline-stages/route.js +107 -12
  12. package/dist/modules/customers/api/pipeline-stages/route.js.map +2 -2
  13. package/dist/modules/customers/api/pipelines/route.js +107 -12
  14. package/dist/modules/customers/api/pipelines/route.js.map +2 -2
  15. package/dist/modules/customers/api/settings/address-format/route.js +33 -1
  16. package/dist/modules/customers/api/settings/address-format/route.js.map +2 -2
  17. package/dist/modules/customers/api/tags/assign/route.js +37 -0
  18. package/dist/modules/customers/api/tags/assign/route.js.map +2 -2
  19. package/dist/modules/customers/api/tags/unassign/route.js +37 -0
  20. package/dist/modules/customers/api/tags/unassign/route.js.map +2 -2
  21. package/dist/modules/customers/components/calendar/CalendarToolbar.js +4 -6
  22. package/dist/modules/customers/components/calendar/CalendarToolbar.js.map +2 -2
  23. package/dist/modules/customers/components/calendar/EventBlock.js +2 -6
  24. package/dist/modules/customers/components/calendar/EventBlock.js.map +2 -2
  25. package/dist/modules/customers/components/calendar/UpcomingCards.js +9 -7
  26. package/dist/modules/customers/components/calendar/UpcomingCards.js.map +2 -2
  27. package/dist/modules/customers/lib/calendar/format.js +27 -0
  28. package/dist/modules/customers/lib/calendar/format.js.map +7 -0
  29. package/dist/modules/entities/api/encryption.js +109 -38
  30. package/dist/modules/entities/api/encryption.js.map +2 -2
  31. package/dist/modules/entities/components/EncryptionManager.js +22 -9
  32. package/dist/modules/entities/components/EncryptionManager.js.map +2 -2
  33. package/dist/modules/integrations/api/[id]/credentials/route.js +23 -1
  34. package/dist/modules/integrations/api/[id]/credentials/route.js.map +2 -2
  35. package/dist/modules/integrations/api/[id]/route.js +10 -3
  36. package/dist/modules/integrations/api/[id]/route.js.map +2 -2
  37. package/dist/modules/integrations/api/[id]/state/route.js +20 -5
  38. package/dist/modules/integrations/api/[id]/state/route.js.map +2 -2
  39. package/dist/modules/integrations/api/[id]/version/route.js +17 -1
  40. package/dist/modules/integrations/api/[id]/version/route.js.map +2 -2
  41. package/dist/modules/integrations/api/route.js +2 -0
  42. package/dist/modules/integrations/api/route.js.map +2 -2
  43. package/dist/modules/integrations/backend/integrations/[id]/page.js +36 -19
  44. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  45. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +13 -9
  46. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  47. package/dist/modules/integrations/backend/integrations/page.js +4 -3
  48. package/dist/modules/integrations/backend/integrations/page.js.map +2 -2
  49. package/dist/modules/integrations/data/entities.js +2 -2
  50. package/dist/modules/integrations/data/entities.js.map +2 -2
  51. package/dist/modules/integrations/lib/credentials-service.js +32 -0
  52. package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
  53. package/dist/modules/integrations/lib/state-service.js +4 -2
  54. package/dist/modules/integrations/lib/state-service.js.map +2 -2
  55. package/dist/modules/notifications/lib/notificationService.js +42 -10
  56. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  57. package/dist/modules/payment_gateways/api/status/route.js +91 -6
  58. package/dist/modules/payment_gateways/api/status/route.js.map +2 -2
  59. package/dist/modules/payment_gateways/backend/payment-gateways/page.js +25 -11
  60. package/dist/modules/payment_gateways/backend/payment-gateways/page.js.map +2 -2
  61. package/dist/modules/sales/backend/sales/channels/offers/page.js +25 -7
  62. package/dist/modules/sales/backend/sales/channels/offers/page.js.map +2 -2
  63. package/dist/modules/sales/backend/sales/channels/page.js +25 -7
  64. package/dist/modules/sales/backend/sales/channels/page.js.map +2 -2
  65. package/dist/modules/sales/components/AdjustmentKindSettings.js +52 -24
  66. package/dist/modules/sales/components/AdjustmentKindSettings.js.map +2 -2
  67. package/dist/modules/sales/components/DocumentNumberSettings.js +26 -9
  68. package/dist/modules/sales/components/DocumentNumberSettings.js.map +2 -2
  69. package/dist/modules/sales/components/OrderEditingSettings.js +26 -9
  70. package/dist/modules/sales/components/OrderEditingSettings.js.map +2 -2
  71. package/dist/modules/sales/components/StatusSettings.js +66 -31
  72. package/dist/modules/sales/components/StatusSettings.js.map +2 -2
  73. package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js +25 -7
  74. package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js.map +2 -2
  75. package/dist/modules/shipping_carriers/api/cancel/route.js +38 -0
  76. package/dist/modules/shipping_carriers/api/cancel/route.js.map +2 -2
  77. package/dist/modules/shipping_carriers/api/shipments/route.js +12 -5
  78. package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
  79. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js +46 -13
  80. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js.map +2 -2
  81. package/dist/modules/staff/lib/timesheets-ui/timerErrors.js +12 -0
  82. package/dist/modules/staff/lib/timesheets-ui/timerErrors.js.map +7 -0
  83. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js +43 -8
  84. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js.map +2 -2
  85. package/dist/modules/workflows/backend/definitions/page.js +19 -10
  86. package/dist/modules/workflows/backend/definitions/page.js.map +2 -2
  87. package/package.json +7 -7
  88. package/src/modules/auth/commands/users.ts +1 -0
  89. package/src/modules/communication_channels/data/enrichers.ts +25 -0
  90. package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.tsx +7 -6
  91. package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.ts +5 -2
  92. package/src/modules/customers/api/pipeline-stages/reorder/route.ts +41 -1
  93. package/src/modules/customers/api/pipeline-stages/route.ts +112 -13
  94. package/src/modules/customers/api/pipelines/route.ts +112 -13
  95. package/src/modules/customers/api/settings/address-format/route.ts +36 -1
  96. package/src/modules/customers/api/tags/assign/route.ts +39 -0
  97. package/src/modules/customers/api/tags/unassign/route.ts +39 -0
  98. package/src/modules/customers/components/calendar/CalendarToolbar.tsx +4 -7
  99. package/src/modules/customers/components/calendar/EventBlock.tsx +2 -6
  100. package/src/modules/customers/components/calendar/UpcomingCards.tsx +9 -7
  101. package/src/modules/customers/i18n/de.json +1 -0
  102. package/src/modules/customers/i18n/en.json +1 -0
  103. package/src/modules/customers/i18n/es.json +1 -0
  104. package/src/modules/customers/i18n/pl.json +1 -0
  105. package/src/modules/customers/lib/calendar/format.ts +25 -0
  106. package/src/modules/entities/api/encryption.ts +122 -39
  107. package/src/modules/entities/components/EncryptionManager.tsx +28 -9
  108. package/src/modules/integrations/api/[id]/credentials/route.ts +23 -0
  109. package/src/modules/integrations/api/[id]/route.ts +8 -1
  110. package/src/modules/integrations/api/[id]/state/route.ts +20 -4
  111. package/src/modules/integrations/api/[id]/version/route.ts +18 -1
  112. package/src/modules/integrations/api/route.ts +3 -0
  113. package/src/modules/integrations/backend/integrations/[id]/page.tsx +39 -20
  114. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +19 -11
  115. package/src/modules/integrations/backend/integrations/page.tsx +8 -5
  116. package/src/modules/integrations/data/entities.ts +2 -2
  117. package/src/modules/integrations/lib/credentials-service.ts +35 -0
  118. package/src/modules/integrations/lib/state-service.ts +3 -0
  119. package/src/modules/notifications/lib/notificationService.ts +74 -11
  120. package/src/modules/payment_gateways/api/status/route.ts +97 -5
  121. package/src/modules/payment_gateways/backend/payment-gateways/page.tsx +27 -11
  122. package/src/modules/sales/backend/sales/channels/offers/page.tsx +31 -7
  123. package/src/modules/sales/backend/sales/channels/page.tsx +31 -7
  124. package/src/modules/sales/components/AdjustmentKindSettings.tsx +60 -24
  125. package/src/modules/sales/components/DocumentNumberSettings.tsx +32 -10
  126. package/src/modules/sales/components/OrderEditingSettings.tsx +32 -10
  127. package/src/modules/sales/components/StatusSettings.tsx +74 -33
  128. package/src/modules/sales/components/channels/SalesChannelOffersPanel.tsx +30 -6
  129. package/src/modules/shipping_carriers/api/cancel/route.ts +46 -0
  130. package/src/modules/shipping_carriers/api/shipments/route.ts +21 -6
  131. package/src/modules/staff/i18n/de.json +5 -5
  132. package/src/modules/staff/i18n/es.json +5 -5
  133. package/src/modules/staff/i18n/pl.json +5 -5
  134. package/src/modules/staff/lib/timesheets-ui/TimerBar.tsx +57 -13
  135. package/src/modules/staff/lib/timesheets-ui/timerErrors.ts +23 -0
  136. package/src/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.tsx +57 -9
  137. package/src/modules/workflows/backend/definitions/page.tsx +19 -10
@@ -13,6 +13,7 @@ import { flash } from '@open-mercato/ui/backend/FlashMessages'
13
13
  import { readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
14
14
  import { deleteCrud } from '@open-mercato/ui/backend/utils/crud'
15
15
  import { buildOptimisticLockHeader, extractOptimisticLockConflict } from '@open-mercato/ui/backend/utils/optimisticLock'
16
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
16
17
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
17
18
  import { useT } from '@open-mercato/shared/lib/i18n/context'
18
19
 
@@ -34,9 +35,27 @@ type ChannelsResponse = {
34
35
 
35
36
  const PAGE_SIZE = 25
36
37
 
38
+ const SAVE_CONTEXT_ID = 'sales-channels-list'
39
+
37
40
  export default function SalesChannelsPage() {
38
41
  const t = useT()
39
42
  const router = useRouter()
43
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
44
+ formId: string
45
+ resourceKind: string
46
+ retryLastMutation: () => Promise<boolean>
47
+ }>({
48
+ contextId: SAVE_CONTEXT_ID,
49
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
50
+ })
51
+ const mutationContext = React.useMemo(
52
+ () => ({
53
+ formId: SAVE_CONTEXT_ID,
54
+ resourceKind: 'sales.channels',
55
+ retryLastMutation,
56
+ }),
57
+ [retryLastMutation],
58
+ )
40
59
  const scopeVersion = useOrganizationScopeVersion()
41
60
  const [rows, setRows] = React.useState<ChannelRow[]>([])
42
61
  const [page, setPage] = React.useState(1)
@@ -139,12 +158,17 @@ export default function SalesChannelsPage() {
139
158
 
140
159
  const handleDelete = React.useCallback(async (row: ChannelRow) => {
141
160
  try {
142
- await withScopedApiRequestHeaders(
143
- buildOptimisticLockHeader(row.updatedAt),
144
- () => deleteCrud('sales/channels', row.id, {
145
- errorMessage: t('sales.channels.table.errors.delete', 'Failed to delete channel.'),
146
- }),
147
- )
161
+ await runMutation({
162
+ operation: () =>
163
+ withScopedApiRequestHeaders(
164
+ buildOptimisticLockHeader(row.updatedAt),
165
+ () => deleteCrud('sales/channels', row.id, {
166
+ errorMessage: t('sales.channels.table.errors.delete', 'Failed to delete channel.'),
167
+ }),
168
+ ),
169
+ context: mutationContext,
170
+ mutationPayload: { action: 'delete', id: row.id },
171
+ })
148
172
  flash(t('sales.channels.table.messages.deleted', 'Channel deleted.'), 'success')
149
173
  handleRefresh()
150
174
  } catch (err) {
@@ -161,7 +185,7 @@ export default function SalesChannelsPage() {
161
185
  }
162
186
  console.error('sales.channels.delete', err)
163
187
  }
164
- }, [handleRefresh, t])
188
+ }, [handleRefresh, mutationContext, runMutation, t])
165
189
 
166
190
  return (
167
191
  <Page>
@@ -18,6 +18,7 @@ import { Label } from '@open-mercato/ui/primitives/label'
18
18
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
19
19
  import { apiCall, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
20
20
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
21
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
21
22
  import { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'
22
23
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
23
24
  import { AppearanceSelector } from '@open-mercato/core/modules/dictionaries/components/AppearanceSelector'
@@ -75,10 +76,28 @@ const normalizeEntry = (raw: any): AdjustmentKind | null => {
75
76
  }
76
77
  }
77
78
 
79
+ const SAVE_CONTEXT_ID = 'sales-adjustment-kind-settings'
80
+
78
81
  export function AdjustmentKindSettings() {
79
82
  const t = useT()
80
83
  const scopeVersion = useOrganizationScopeVersion()
81
84
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
85
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
86
+ formId: string
87
+ resourceKind: string
88
+ retryLastMutation: () => Promise<boolean>
89
+ }>({
90
+ contextId: SAVE_CONTEXT_ID,
91
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
92
+ })
93
+ const mutationContext = React.useMemo(
94
+ () => ({
95
+ formId: SAVE_CONTEXT_ID,
96
+ resourceKind: 'sales.adjustmentKinds',
97
+ retryLastMutation,
98
+ }),
99
+ [retryLastMutation],
100
+ )
82
101
  const [items, setItems] = React.useState<AdjustmentKind[]>([])
83
102
  const [search, setSearch] = React.useState('')
84
103
  const [loading, setLoading] = React.useState(false)
@@ -222,16 +241,26 @@ export function AdjustmentKindSettings() {
222
241
 
223
242
  const lockHeader =
224
243
  dialog.mode === 'edit' ? buildOptimisticLockHeader(dialog.entry.updatedAt) : {}
225
- const call = await withScopedApiRequestHeaders(lockHeader, () =>
226
- apiCall('/api/sales/adjustment-kinds', {
227
- method,
228
- headers: { 'content-type': 'application/json' },
229
- body,
230
- })
231
- )
232
- if (!call.ok) {
233
- await raiseCrudError(call.response, labels.saveError)
234
- }
244
+ await runMutation({
245
+ operation: async () => {
246
+ const call = await withScopedApiRequestHeaders(lockHeader, () =>
247
+ apiCall('/api/sales/adjustment-kinds', {
248
+ method,
249
+ headers: { 'content-type': 'application/json' },
250
+ body,
251
+ })
252
+ )
253
+ if (!call.ok) {
254
+ await raiseCrudError(call.response, labels.saveError)
255
+ }
256
+ return call
257
+ },
258
+ context: mutationContext,
259
+ mutationPayload:
260
+ dialog.mode === 'create'
261
+ ? { action: 'create', ...payload }
262
+ : { action: 'update', id: dialog.entry.id, ...payload },
263
+ })
235
264
  flash(dialog.mode === 'create' ? labels.created : labels.updated, 'success')
236
265
  closeDialog()
237
266
  await loadItems()
@@ -242,7 +271,7 @@ export function AdjustmentKindSettings() {
242
271
  } finally {
243
272
  setSubmitting(false)
244
273
  }
245
- }, [closeDialog, dialog, form.color, form.icon, form.label, form.value, labels, loadItems])
274
+ }, [closeDialog, dialog, form.color, form.icon, form.label, form.value, labels, loadItems, mutationContext, runMutation])
246
275
 
247
276
  const handleDelete = React.useCallback(
248
277
  async (entry: AdjustmentKind) => {
@@ -253,18 +282,25 @@ export function AdjustmentKindSettings() {
253
282
  })
254
283
  if (!confirmed) return
255
284
  try {
256
- const call = await withScopedApiRequestHeaders(
257
- buildOptimisticLockHeader(entry.updatedAt),
258
- () =>
259
- apiCall('/api/sales/adjustment-kinds', {
260
- method: 'DELETE',
261
- headers: { 'content-type': 'application/json' },
262
- body: JSON.stringify({ id: entry.id }),
263
- })
264
- )
265
- if (!call.ok) {
266
- await raiseCrudError(call.response, labels.deleteError)
267
- }
285
+ await runMutation({
286
+ operation: async () => {
287
+ const call = await withScopedApiRequestHeaders(
288
+ buildOptimisticLockHeader(entry.updatedAt),
289
+ () =>
290
+ apiCall('/api/sales/adjustment-kinds', {
291
+ method: 'DELETE',
292
+ headers: { 'content-type': 'application/json' },
293
+ body: JSON.stringify({ id: entry.id }),
294
+ })
295
+ )
296
+ if (!call.ok) {
297
+ await raiseCrudError(call.response, labels.deleteError)
298
+ }
299
+ return call
300
+ },
301
+ context: mutationContext,
302
+ mutationPayload: { action: 'delete', id: entry.id },
303
+ })
268
304
  flash(labels.deleted, 'success')
269
305
  await loadItems()
270
306
  } catch (err) {
@@ -273,7 +309,7 @@ export function AdjustmentKindSettings() {
273
309
  flash(message, 'error')
274
310
  }
275
311
  },
276
- [confirm, labels, loadItems]
312
+ [confirm, labels, loadItems, mutationContext, runMutation]
277
313
  )
278
314
 
279
315
  const formKeyHandler = React.useCallback(
@@ -6,6 +6,7 @@ import { Button } from '@open-mercato/ui/primitives/button'
6
6
  import { Input } from '@open-mercato/ui/primitives/input'
7
7
  import { Badge } from '@open-mercato/ui/primitives/badge'
8
8
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
9
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
9
10
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
10
11
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
11
12
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -56,6 +57,8 @@ const normalizeState = (payload?: Partial<SettingsResponse> | null): FormState =
56
57
  : '1',
57
58
  })
58
59
 
60
+ const SAVE_CONTEXT_ID = 'sales-document-number-settings'
61
+
59
62
  export function DocumentNumberSettings() {
60
63
  const t = useT()
61
64
  const scopeVersion = useOrganizationScopeVersion()
@@ -64,6 +67,15 @@ export function DocumentNumberSettings() {
64
67
  const [saving, setSaving] = React.useState(false)
65
68
  const [tokens, setTokens] = React.useState(DOCUMENT_NUMBER_TOKENS)
66
69
 
70
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
71
+ formId: string
72
+ resourceKind: string
73
+ retryLastMutation: () => Promise<boolean>
74
+ }>({
75
+ contextId: SAVE_CONTEXT_ID,
76
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
77
+ })
78
+
67
79
  const translations = React.useMemo(() => ({
68
80
  title: t('sales.config.numbering.title', 'Document numbers'),
69
81
  description: t(
@@ -128,16 +140,26 @@ export function DocumentNumberSettings() {
128
140
  orderNextNumber: Number.parseInt(formState.orderNextNumber, 10) || undefined,
129
141
  quoteNextNumber: Number.parseInt(formState.quoteNextNumber, 10) || undefined,
130
142
  }
131
- // optimistic-lock-exempt: single-row tenant numbering settings blob — no per-record version / concurrent record edit
132
- const call = await apiCall<SettingsResponse>('/api/sales/settings/document-numbers', {
133
- method: 'PUT',
134
- headers: { 'content-type': 'application/json' },
135
- body: JSON.stringify(payload),
143
+ const call = await runMutation({
144
+ // optimistic-lock-exempt: single-row tenant numbering settings blob — no per-record version / concurrent record edit
145
+ operation: async () => {
146
+ const response = await apiCall<SettingsResponse>('/api/sales/settings/document-numbers', {
147
+ method: 'PUT',
148
+ headers: { 'content-type': 'application/json' },
149
+ body: JSON.stringify(payload),
150
+ })
151
+ if (!response.ok) {
152
+ throw new Error(translations.errors.save)
153
+ }
154
+ return response
155
+ },
156
+ context: {
157
+ formId: SAVE_CONTEXT_ID,
158
+ resourceKind: 'sales.settings',
159
+ retryLastMutation,
160
+ },
161
+ mutationPayload: payload,
136
162
  })
137
- if (!call.ok) {
138
- flash(translations.errors.save, 'error')
139
- return
140
- }
141
163
  setFormState(normalizeState(call.result))
142
164
  setTokens(Array.isArray(call.result?.tokens) && call.result.tokens.length ? call.result.tokens : DOCUMENT_NUMBER_TOKENS)
143
165
  flash(translations.messages.saved, 'success')
@@ -147,7 +169,7 @@ export function DocumentNumberSettings() {
147
169
  } finally {
148
170
  setSaving(false)
149
171
  }
150
- }, [formState.orderNextNumber, formState.orderNumberFormat, formState.quoteNextNumber, formState.quoteNumberFormat, translations.errors.save, translations.messages.saved])
172
+ }, [formState.orderNextNumber, formState.orderNumberFormat, formState.quoteNextNumber, formState.quoteNumberFormat, retryLastMutation, runMutation, translations.errors.save, translations.messages.saved])
151
173
 
152
174
  const handleReset = React.useCallback(() => {
153
175
  setFormState(DEFAULT_STATE)
@@ -6,6 +6,7 @@ import { Button } from '@open-mercato/ui/primitives/button'
6
6
  import { Switch } from '@open-mercato/ui/primitives/switch'
7
7
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
8
8
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
9
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
9
10
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
10
11
  import { useT } from '@open-mercato/shared/lib/i18n/context'
11
12
 
@@ -35,6 +36,8 @@ const normalizeStatusList = (list: unknown): string[] | null => {
35
36
  return Array.from(set)
36
37
  }
37
38
 
39
+ const SAVE_CONTEXT_ID = 'sales-order-editing-settings'
40
+
38
41
  export function OrderEditingSettings() {
39
42
  const t = useT()
40
43
  const scopeVersion = useOrganizationScopeVersion()
@@ -44,6 +47,15 @@ export function OrderEditingSettings() {
44
47
  const [customerStatuses, setCustomerStatuses] = React.useState<string[] | null>(null)
45
48
  const [addressStatuses, setAddressStatuses] = React.useState<string[] | null>(null)
46
49
 
50
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
51
+ formId: string
52
+ resourceKind: string
53
+ retryLastMutation: () => Promise<boolean>
54
+ }>({
55
+ contextId: SAVE_CONTEXT_ID,
56
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
57
+ })
58
+
47
59
  const translations = React.useMemo(
48
60
  () => ({
49
61
  title: t('sales.config.orderEditing.title', 'Order editing guards'),
@@ -121,16 +133,26 @@ export function OrderEditingSettings() {
121
133
  orderCustomerEditableStatuses: customerStatuses,
122
134
  orderAddressEditableStatuses: addressStatuses,
123
135
  }
124
- // optimistic-lock-exempt: single-row tenant order-editing settings blob — no per-record version / concurrent record edit
125
- const call = await apiCall<SettingsResponse>('/api/sales/settings/order-editing', {
126
- method: 'PUT',
127
- headers: { 'content-type': 'application/json' },
128
- body: JSON.stringify(payload),
136
+ const call = await runMutation({
137
+ // optimistic-lock-exempt: single-row tenant order-editing settings blob — no per-record version / concurrent record edit
138
+ operation: async () => {
139
+ const response = await apiCall<SettingsResponse>('/api/sales/settings/order-editing', {
140
+ method: 'PUT',
141
+ headers: { 'content-type': 'application/json' },
142
+ body: JSON.stringify(payload),
143
+ })
144
+ if (!response.ok) {
145
+ throw new Error(translations.messages.saveError)
146
+ }
147
+ return response
148
+ },
149
+ context: {
150
+ formId: SAVE_CONTEXT_ID,
151
+ resourceKind: 'sales.settings',
152
+ retryLastMutation,
153
+ },
154
+ mutationPayload: payload,
129
155
  })
130
- if (!call.ok) {
131
- flash(translations.messages.saveError, 'error')
132
- return
133
- }
134
156
  setCustomerStatuses(normalizeStatusList(call.result?.orderCustomerEditableStatuses))
135
157
  setAddressStatuses(normalizeStatusList(call.result?.orderAddressEditableStatuses))
136
158
  setOptions(Array.isArray(call.result?.orderStatuses) ? call.result.orderStatuses : [])
@@ -141,7 +163,7 @@ export function OrderEditingSettings() {
141
163
  } finally {
142
164
  setSaving(false)
143
165
  }
144
- }, [addressStatuses, customerStatuses, translations.messages.saveError, translations.messages.saved])
166
+ }, [addressStatuses, customerStatuses, retryLastMutation, runMutation, translations.messages.saveError, translations.messages.saved])
145
167
 
146
168
  const renderStatusList = React.useCallback(
147
169
  (kind: 'customer' | 'address', values: string[] | null, label: string) => {
@@ -10,6 +10,7 @@ import {
10
10
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
11
11
  import { apiCall, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
12
12
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
13
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
13
14
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
14
15
  import { useT } from '@open-mercato/shared/lib/i18n/context'
15
16
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
@@ -43,6 +44,8 @@ const DEFAULT_FORM_VALUES: DictionaryFormValues = {
43
44
  icon: null,
44
45
  }
45
46
 
47
+ const SAVE_CONTEXT_ID = 'sales-status-settings'
48
+
46
49
  export function StatusSettings() {
47
50
  const t = useT()
48
51
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
@@ -51,6 +54,23 @@ export function StatusSettings() {
51
54
  return value === key ? fallback : value
52
55
  }, [t])
53
56
 
57
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
58
+ formId: string
59
+ resourceKind: string
60
+ retryLastMutation: () => Promise<boolean>
61
+ }>({
62
+ contextId: SAVE_CONTEXT_ID,
63
+ blockedMessage: translate('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
64
+ })
65
+ const mutationContext = React.useMemo(
66
+ () => ({
67
+ formId: SAVE_CONTEXT_ID,
68
+ resourceKind: 'sales.statuses',
69
+ retryLastMutation,
70
+ }),
71
+ [retryLastMutation],
72
+ )
73
+
54
74
  const sections = React.useMemo<SectionDefinition[]>(() => [
55
75
  {
56
76
  kind: 'order-statuses',
@@ -198,18 +218,25 @@ export function StatusSettings() {
198
218
  })
199
219
  if (!confirmed) return
200
220
  try {
201
- const call = await withScopedApiRequestHeaders(
202
- buildOptimisticLockHeader(entry.updatedAt),
203
- () =>
204
- apiCall(apiPaths[kind], {
205
- method: 'DELETE',
206
- headers: { 'content-type': 'application/json' },
207
- body: JSON.stringify({ id: entry.id }),
208
- })
209
- )
210
- if (!call.ok) {
211
- await raiseCrudError(call.response, translate('sales.config.statuses.error.delete', 'Failed to delete status.'))
212
- }
221
+ await runMutation({
222
+ operation: async () => {
223
+ const call = await withScopedApiRequestHeaders(
224
+ buildOptimisticLockHeader(entry.updatedAt),
225
+ () =>
226
+ apiCall(apiPaths[kind], {
227
+ method: 'DELETE',
228
+ headers: { 'content-type': 'application/json' },
229
+ body: JSON.stringify({ id: entry.id }),
230
+ })
231
+ )
232
+ if (!call.ok) {
233
+ await raiseCrudError(call.response, translate('sales.config.statuses.error.delete', 'Failed to delete status.'))
234
+ }
235
+ return call
236
+ },
237
+ context: mutationContext,
238
+ mutationPayload: { action: 'delete', kind, id: entry.id },
239
+ })
213
240
  flash(translate('sales.config.statuses.success.delete', 'Status deleted.'), 'success')
214
241
  await loadEntries(kind)
215
242
  } catch (err) {
@@ -217,7 +244,7 @@ export function StatusSettings() {
217
244
  const message = err instanceof Error ? err.message : translate('sales.config.statuses.error.delete', 'Failed to delete status.')
218
245
  flash(message, 'error')
219
246
  }
220
- }, [apiPaths, confirm, loadEntries, translate])
247
+ }, [apiPaths, confirm, loadEntries, mutationContext, runMutation, translate])
221
248
 
222
249
  const submitForm = React.useCallback(async (values: DictionaryFormValues) => {
223
250
  if (!dialog) return
@@ -225,14 +252,21 @@ export function StatusSettings() {
225
252
  setSubmitting(true)
226
253
  try {
227
254
  if (dialog.mode === 'create') {
228
- const call = await apiCall(path, {
229
- method: 'POST',
230
- headers: { 'content-type': 'application/json' },
231
- body: JSON.stringify(values),
255
+ await runMutation({
256
+ operation: async () => {
257
+ const call = await apiCall(path, {
258
+ method: 'POST',
259
+ headers: { 'content-type': 'application/json' },
260
+ body: JSON.stringify(values),
261
+ })
262
+ if (!call.ok) {
263
+ await raiseCrudError(call.response, translate('sales.config.statuses.error.save', 'Failed to save status.'))
264
+ }
265
+ return call
266
+ },
267
+ context: mutationContext,
268
+ mutationPayload: { action: 'create', kind: dialog.kind, ...values },
232
269
  })
233
- if (!call.ok) {
234
- await raiseCrudError(call.response, translate('sales.config.statuses.error.save', 'Failed to save status.'))
235
- }
236
270
  flash(translate('sales.config.statuses.success.save', 'Status saved.'), 'success')
237
271
  } else if (dialog.mode === 'edit') {
238
272
  const entry = dialog.entry
@@ -243,18 +277,25 @@ export function StatusSettings() {
243
277
  if (nextColor !== (entry.color ?? null)) body.color = nextColor
244
278
  const nextIcon = values.icon ?? null
245
279
  if (nextIcon !== (entry.icon ?? null)) body.icon = nextIcon
246
- const call = await withScopedApiRequestHeaders(
247
- buildOptimisticLockHeader(entry.updatedAt),
248
- () =>
249
- apiCall(path, {
250
- method: 'PUT',
251
- headers: { 'content-type': 'application/json' },
252
- body: JSON.stringify(body),
253
- })
254
- )
255
- if (!call.ok) {
256
- await raiseCrudError(call.response, translate('sales.config.statuses.error.save', 'Failed to save status.'))
257
- }
280
+ await runMutation({
281
+ operation: async () => {
282
+ const call = await withScopedApiRequestHeaders(
283
+ buildOptimisticLockHeader(entry.updatedAt),
284
+ () =>
285
+ apiCall(path, {
286
+ method: 'PUT',
287
+ headers: { 'content-type': 'application/json' },
288
+ body: JSON.stringify(body),
289
+ })
290
+ )
291
+ if (!call.ok) {
292
+ await raiseCrudError(call.response, translate('sales.config.statuses.error.save', 'Failed to save status.'))
293
+ }
294
+ return call
295
+ },
296
+ context: mutationContext,
297
+ mutationPayload: { action: 'update', kind: dialog.kind, ...body },
298
+ })
258
299
  flash(translate('sales.config.statuses.success.save', 'Status saved.'), 'success')
259
300
  }
260
301
  closeDialog()
@@ -267,7 +308,7 @@ export function StatusSettings() {
267
308
  } finally {
268
309
  setSubmitting(false)
269
310
  }
270
- }, [apiPaths, closeDialog, dialog, loadEntries, translate])
311
+ }, [apiPaths, closeDialog, dialog, loadEntries, mutationContext, runMutation, translate])
271
312
 
272
313
  const currentValues = React.useMemo<DictionaryFormValues>(() => {
273
314
  if (dialog && dialog.mode === 'edit') {
@@ -13,6 +13,7 @@ import { readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato
13
13
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
14
14
  import { deleteCrud } from '@open-mercato/ui/backend/utils/crud'
15
15
  import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
16
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
16
17
  import { useT } from '@open-mercato/shared/lib/i18n/context'
17
18
  import { mapOfferRow, renderOfferPriceSummary, type OfferRow } from './offerTableUtils'
18
19
 
@@ -24,9 +25,27 @@ type OffersResponse = {
24
25
 
25
26
  const PAGE_SIZE = 25
26
27
 
28
+ const SAVE_CONTEXT_ID = 'sales-channel-offers-panel'
29
+
27
30
  export function SalesChannelOffersPanel({ channelId, channelName }: { channelId: string; channelName?: string }) {
28
31
  const t = useT()
29
32
  const router = useRouter()
33
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
34
+ formId: string
35
+ resourceKind: string
36
+ retryLastMutation: () => Promise<boolean>
37
+ }>({
38
+ contextId: SAVE_CONTEXT_ID,
39
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
40
+ })
41
+ const mutationContext = React.useMemo(
42
+ () => ({
43
+ formId: SAVE_CONTEXT_ID,
44
+ resourceKind: 'sales.channels.offers',
45
+ retryLastMutation,
46
+ }),
47
+ [retryLastMutation],
48
+ )
30
49
  const [rows, setRows] = React.useState<OfferRow[]>([])
31
50
  const [page, setPage] = React.useState(1)
32
51
  const [total, setTotal] = React.useState(0)
@@ -125,11 +144,16 @@ export function SalesChannelOffersPanel({ channelId, channelName }: { channelId:
125
144
 
126
145
  const handleDelete = React.useCallback(async (row: OfferRow) => {
127
146
  try {
128
- await withScopedApiRequestHeaders(buildOptimisticLockHeader(row.updatedAt), () =>
129
- deleteCrud('catalog/offers', row.id, {
130
- errorMessage: t('sales.channels.offers.errors.delete', 'Failed to delete offer.'),
131
- }),
132
- )
147
+ await runMutation({
148
+ operation: () =>
149
+ withScopedApiRequestHeaders(buildOptimisticLockHeader(row.updatedAt), () =>
150
+ deleteCrud('catalog/offers', row.id, {
151
+ errorMessage: t('sales.channels.offers.errors.delete', 'Failed to delete offer.'),
152
+ }),
153
+ ),
154
+ context: mutationContext,
155
+ mutationPayload: { action: 'delete', id: row.id },
156
+ })
133
157
  flash(t('sales.channels.offers.messages.deleted', 'Offer deleted.'), 'success')
134
158
  setReloadToken((token) => token + 1)
135
159
  } catch (err) {
@@ -137,7 +161,7 @@ export function SalesChannelOffersPanel({ channelId, channelName }: { channelId:
137
161
  console.error('sales.channels.offers.delete', err)
138
162
  flash(t('sales.channels.offers.errors.delete', 'Failed to delete offer.'), 'error')
139
163
  }
140
- }, [t])
164
+ }, [mutationContext, runMutation, t])
141
165
 
142
166
  const handleSearchChange = React.useCallback((value: string) => {
143
167
  setSearch(value)
@@ -2,11 +2,26 @@ import { NextResponse } from 'next/server'
2
2
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
3
3
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
5
+ import {
6
+ runCrudMutationGuardAfterSuccess,
7
+ validateCrudMutationGuard,
8
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
5
9
  import type { ShippingCarrierService } from '../../lib/shipping-service'
6
10
  import { isShipmentCancelNotAllowedError } from '../../lib/status-sync'
7
11
  import { cancelShipmentSchema } from '../../data/validators'
8
12
  import { shippingCarriersTag } from '../openapi'
9
13
 
14
+ function resolveGuardUserId(auth: {
15
+ sub?: string | null
16
+ userId?: string | null
17
+ keyId?: string | null
18
+ }): string {
19
+ if (typeof auth.sub === 'string' && auth.sub.trim().length > 0) return auth.sub
20
+ if (typeof auth.userId === 'string' && auth.userId.trim().length > 0) return auth.userId
21
+ if (typeof auth.keyId === 'string' && auth.keyId.trim().length > 0) return auth.keyId
22
+ return 'system'
23
+ }
24
+
10
25
  export const metadata = {
11
26
  path: '/shipping-carriers/cancel',
12
27
  POST: { requireAuth: true, requireFeatures: ['shipping_carriers.manage'] },
@@ -23,6 +38,22 @@ export async function POST(req: Request) {
23
38
  return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 422 })
24
39
  }
25
40
  const container = await createRequestContainer()
41
+ const guardUserId = resolveGuardUserId(auth)
42
+ const guardResult = await validateCrudMutationGuard(container, {
43
+ tenantId: auth.tenantId,
44
+ organizationId: auth.orgId,
45
+ userId: guardUserId,
46
+ resourceKind: 'shipping_carriers.shipment',
47
+ resourceId: parsed.data.shipmentId,
48
+ operation: 'custom',
49
+ requestMethod: req.method,
50
+ requestHeaders: req.headers,
51
+ mutationPayload: parsed.data as Record<string, unknown>,
52
+ })
53
+ if (guardResult && !guardResult.ok) {
54
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
55
+ }
56
+
26
57
  const service = container.resolve('shippingCarrierService') as ShippingCarrierService
27
58
  try {
28
59
  const result = await service.cancelShipment({
@@ -30,6 +61,21 @@ export async function POST(req: Request) {
30
61
  organizationId: auth.orgId as string,
31
62
  tenantId: auth.tenantId,
32
63
  })
64
+
65
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
66
+ await runCrudMutationGuardAfterSuccess(container, {
67
+ tenantId: auth.tenantId,
68
+ organizationId: auth.orgId,
69
+ userId: guardUserId,
70
+ resourceKind: 'shipping_carriers.shipment',
71
+ resourceId: parsed.data.shipmentId,
72
+ operation: 'custom',
73
+ requestMethod: req.method,
74
+ requestHeaders: req.headers,
75
+ metadata: guardResult.metadata ?? null,
76
+ })
77
+ }
78
+
33
79
  return NextResponse.json(result)
34
80
  } catch (error: unknown) {
35
81
  if (isShipmentCancelNotAllowedError(error)) {