@open-mercato/core 0.6.6-develop.6229.1.ebe6706732 → 0.6.6-develop.6241.1.47c01ad4ed

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 (125) 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/entities/api/encryption.js +109 -38
  22. package/dist/modules/entities/api/encryption.js.map +2 -2
  23. package/dist/modules/entities/components/EncryptionManager.js +22 -9
  24. package/dist/modules/entities/components/EncryptionManager.js.map +2 -2
  25. package/dist/modules/integrations/api/[id]/credentials/route.js +23 -1
  26. package/dist/modules/integrations/api/[id]/credentials/route.js.map +2 -2
  27. package/dist/modules/integrations/api/[id]/route.js +10 -3
  28. package/dist/modules/integrations/api/[id]/route.js.map +2 -2
  29. package/dist/modules/integrations/api/[id]/state/route.js +20 -5
  30. package/dist/modules/integrations/api/[id]/state/route.js.map +2 -2
  31. package/dist/modules/integrations/api/[id]/version/route.js +17 -1
  32. package/dist/modules/integrations/api/[id]/version/route.js.map +2 -2
  33. package/dist/modules/integrations/api/route.js +2 -0
  34. package/dist/modules/integrations/api/route.js.map +2 -2
  35. package/dist/modules/integrations/backend/integrations/[id]/page.js +36 -19
  36. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  37. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +13 -9
  38. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  39. package/dist/modules/integrations/backend/integrations/page.js +4 -3
  40. package/dist/modules/integrations/backend/integrations/page.js.map +2 -2
  41. package/dist/modules/integrations/data/entities.js +2 -2
  42. package/dist/modules/integrations/data/entities.js.map +2 -2
  43. package/dist/modules/integrations/lib/credentials-service.js +32 -0
  44. package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
  45. package/dist/modules/integrations/lib/state-service.js +4 -2
  46. package/dist/modules/integrations/lib/state-service.js.map +2 -2
  47. package/dist/modules/notifications/lib/notificationService.js +42 -10
  48. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  49. package/dist/modules/payment_gateways/api/status/route.js +91 -6
  50. package/dist/modules/payment_gateways/api/status/route.js.map +2 -2
  51. package/dist/modules/payment_gateways/backend/payment-gateways/page.js +25 -11
  52. package/dist/modules/payment_gateways/backend/payment-gateways/page.js.map +2 -2
  53. package/dist/modules/sales/backend/sales/channels/offers/page.js +25 -7
  54. package/dist/modules/sales/backend/sales/channels/offers/page.js.map +2 -2
  55. package/dist/modules/sales/backend/sales/channels/page.js +25 -7
  56. package/dist/modules/sales/backend/sales/channels/page.js.map +2 -2
  57. package/dist/modules/sales/components/AdjustmentKindSettings.js +52 -24
  58. package/dist/modules/sales/components/AdjustmentKindSettings.js.map +2 -2
  59. package/dist/modules/sales/components/DocumentNumberSettings.js +26 -9
  60. package/dist/modules/sales/components/DocumentNumberSettings.js.map +2 -2
  61. package/dist/modules/sales/components/OrderEditingSettings.js +26 -9
  62. package/dist/modules/sales/components/OrderEditingSettings.js.map +2 -2
  63. package/dist/modules/sales/components/StatusSettings.js +66 -31
  64. package/dist/modules/sales/components/StatusSettings.js.map +2 -2
  65. package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js +25 -7
  66. package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js.map +2 -2
  67. package/dist/modules/shipping_carriers/api/cancel/route.js +38 -0
  68. package/dist/modules/shipping_carriers/api/cancel/route.js.map +2 -2
  69. package/dist/modules/shipping_carriers/api/shipments/route.js +12 -5
  70. package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
  71. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js +7 -8
  72. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js.map +2 -2
  73. package/dist/modules/staff/lib/timesheets-ui/timerErrors.js +12 -0
  74. package/dist/modules/staff/lib/timesheets-ui/timerErrors.js.map +7 -0
  75. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js +4 -3
  76. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js.map +2 -2
  77. package/dist/modules/workflows/backend/definitions/page.js +19 -10
  78. package/dist/modules/workflows/backend/definitions/page.js.map +2 -2
  79. package/package.json +7 -7
  80. package/src/modules/auth/commands/users.ts +1 -0
  81. package/src/modules/communication_channels/data/enrichers.ts +25 -0
  82. package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.client.tsx +7 -6
  83. package/src/modules/communication_channels/widgets/injection/channel-payload-renderer/widget.ts +5 -2
  84. package/src/modules/customers/api/pipeline-stages/reorder/route.ts +41 -1
  85. package/src/modules/customers/api/pipeline-stages/route.ts +112 -13
  86. package/src/modules/customers/api/pipelines/route.ts +112 -13
  87. package/src/modules/customers/api/settings/address-format/route.ts +36 -1
  88. package/src/modules/customers/api/tags/assign/route.ts +39 -0
  89. package/src/modules/customers/api/tags/unassign/route.ts +39 -0
  90. package/src/modules/customers/i18n/de.json +1 -0
  91. package/src/modules/customers/i18n/en.json +1 -0
  92. package/src/modules/customers/i18n/es.json +1 -0
  93. package/src/modules/customers/i18n/pl.json +1 -0
  94. package/src/modules/entities/api/encryption.ts +122 -39
  95. package/src/modules/entities/components/EncryptionManager.tsx +28 -9
  96. package/src/modules/integrations/api/[id]/credentials/route.ts +23 -0
  97. package/src/modules/integrations/api/[id]/route.ts +8 -1
  98. package/src/modules/integrations/api/[id]/state/route.ts +20 -4
  99. package/src/modules/integrations/api/[id]/version/route.ts +18 -1
  100. package/src/modules/integrations/api/route.ts +3 -0
  101. package/src/modules/integrations/backend/integrations/[id]/page.tsx +39 -20
  102. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +19 -11
  103. package/src/modules/integrations/backend/integrations/page.tsx +8 -5
  104. package/src/modules/integrations/data/entities.ts +2 -2
  105. package/src/modules/integrations/lib/credentials-service.ts +35 -0
  106. package/src/modules/integrations/lib/state-service.ts +3 -0
  107. package/src/modules/notifications/lib/notificationService.ts +74 -11
  108. package/src/modules/payment_gateways/api/status/route.ts +97 -5
  109. package/src/modules/payment_gateways/backend/payment-gateways/page.tsx +27 -11
  110. package/src/modules/sales/backend/sales/channels/offers/page.tsx +31 -7
  111. package/src/modules/sales/backend/sales/channels/page.tsx +31 -7
  112. package/src/modules/sales/components/AdjustmentKindSettings.tsx +60 -24
  113. package/src/modules/sales/components/DocumentNumberSettings.tsx +32 -10
  114. package/src/modules/sales/components/OrderEditingSettings.tsx +32 -10
  115. package/src/modules/sales/components/StatusSettings.tsx +74 -33
  116. package/src/modules/sales/components/channels/SalesChannelOffersPanel.tsx +30 -6
  117. package/src/modules/shipping_carriers/api/cancel/route.ts +46 -0
  118. package/src/modules/shipping_carriers/api/shipments/route.ts +21 -6
  119. package/src/modules/staff/i18n/de.json +5 -5
  120. package/src/modules/staff/i18n/es.json +5 -5
  121. package/src/modules/staff/i18n/pl.json +5 -5
  122. package/src/modules/staff/lib/timesheets-ui/TimerBar.tsx +7 -8
  123. package/src/modules/staff/lib/timesheets-ui/timerErrors.ts +23 -0
  124. package/src/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.tsx +4 -3
  125. package/src/modules/workflows/backend/definitions/page.tsx +19 -10
@@ -290,19 +290,35 @@ export default function PaymentTransactionsPage() {
290
290
  const handleRefreshStatus = React.useCallback(async () => {
291
291
  if (!selectedId) return
292
292
  setIsRefreshingStatus(true)
293
- const call = await apiCall(`/api/payment_gateways/status?transactionId=${encodeURIComponent(selectedId)}`, undefined, { fallback: null })
294
- if (!call.ok) {
295
- flash(t('payment_gateways.transactions.error.refreshStatus', 'Failed to refresh transaction status'), 'error')
293
+ try {
294
+ await runMutation({
295
+ operation: async () => {
296
+ await apiCallOrThrow('/api/payment_gateways/status', {
297
+ method: 'POST',
298
+ headers: { 'content-type': 'application/json' },
299
+ body: JSON.stringify({ transactionId: selectedId }),
300
+ })
301
+ },
302
+ context: {
303
+ entityType: 'payment_gateways:gateway_transaction',
304
+ entityId: selectedId,
305
+ },
306
+ mutationPayload: { transactionId: selectedId },
307
+ })
308
+ await Promise.all([
309
+ loadRows(),
310
+ loadDetail(selectedId),
311
+ ])
312
+ flash(t('payment_gateways.transactions.success.refreshStatus', 'Transaction status refreshed'), 'success')
313
+ } catch (error) {
314
+ const message = error instanceof Error && error.message
315
+ ? error.message
316
+ : t('payment_gateways.transactions.error.refreshStatus', 'Failed to refresh transaction status')
317
+ flash(message, 'error')
318
+ } finally {
296
319
  setIsRefreshingStatus(false)
297
- return
298
320
  }
299
- await Promise.all([
300
- loadRows(),
301
- loadDetail(selectedId),
302
- ])
303
- flash(t('payment_gateways.transactions.success.refreshStatus', 'Transaction status refreshed'), 'success')
304
- setIsRefreshingStatus(false)
305
- }, [loadDetail, loadRows, selectedId, t])
321
+ }, [loadDetail, loadRows, runMutation, selectedId, t])
306
322
 
307
323
  const handleCapturePayment = React.useCallback(async () => {
308
324
  if (!selectedId) return
@@ -14,6 +14,7 @@ import { readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato
14
14
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
15
15
  import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
16
16
  import { deleteCrud } from '@open-mercato/ui/backend/utils/crud'
17
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
17
18
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
18
19
  import { useT } from '@open-mercato/shared/lib/i18n/context'
19
20
  import { mapOfferRow, renderOfferPriceSummary, type OfferRow } from '@open-mercato/core/modules/sales/components/channels/offerTableUtils'
@@ -26,9 +27,27 @@ type OffersResponse = {
26
27
 
27
28
  const PAGE_SIZE = 25
28
29
 
30
+ const SAVE_CONTEXT_ID = 'sales-channel-offers-list'
31
+
29
32
  export default function SalesChannelOffersListPage() {
30
33
  const t = useT()
31
34
  const router = useRouter()
35
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
36
+ formId: string
37
+ resourceKind: string
38
+ retryLastMutation: () => Promise<boolean>
39
+ }>({
40
+ contextId: SAVE_CONTEXT_ID,
41
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
42
+ })
43
+ const mutationContext = React.useMemo(
44
+ () => ({
45
+ formId: SAVE_CONTEXT_ID,
46
+ resourceKind: 'sales.channels.offers',
47
+ retryLastMutation,
48
+ }),
49
+ [retryLastMutation],
50
+ )
32
51
  const scopeVersion = useOrganizationScopeVersion()
33
52
  const channelOptionsRef = React.useRef<Map<string, FilterOption>>(new Map())
34
53
  const [rows, setRows] = React.useState<OfferRow[]>([])
@@ -282,19 +301,24 @@ export default function SalesChannelOffersListPage() {
282
301
 
283
302
  const handleDelete = React.useCallback(async (row: OfferRow) => {
284
303
  try {
285
- await withScopedApiRequestHeaders(
286
- buildOptimisticLockHeader(row.updatedAt),
287
- () => deleteCrud('catalog/offers', row.id, {
288
- errorMessage: t('sales.channels.offers.errors.delete', 'Failed to delete offer.'),
289
- }),
290
- )
304
+ await runMutation({
305
+ operation: () =>
306
+ withScopedApiRequestHeaders(
307
+ buildOptimisticLockHeader(row.updatedAt),
308
+ () => deleteCrud('catalog/offers', row.id, {
309
+ errorMessage: t('sales.channels.offers.errors.delete', 'Failed to delete offer.'),
310
+ }),
311
+ ),
312
+ context: mutationContext,
313
+ mutationPayload: { action: 'delete', id: row.id },
314
+ })
291
315
  flash(t('sales.channels.offers.messages.deleted', 'Offer deleted.'), 'success')
292
316
  handleRefresh()
293
317
  } catch (err) {
294
318
  if (surfaceRecordConflict(err, t)) { handleRefresh(); return }
295
319
  console.error('sales.channels.offers.delete', err)
296
320
  }
297
- }, [handleRefresh, t])
321
+ }, [handleRefresh, mutationContext, runMutation, t])
298
322
 
299
323
  const tableTitle = (
300
324
  <div className="flex flex-col gap-1">
@@ -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') {