@open-mercato/core 0.6.6-develop.6198.1.0822f97cc6 → 0.6.6-develop.6204.1.30b1f58642

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 (46) hide show
  1. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +52 -45
  2. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  3. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js +9 -13
  4. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js.map +2 -2
  5. package/dist/modules/customers/components/detail/ActivitiesSection.js +17 -12
  6. package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
  7. package/dist/modules/customers/components/detail/ActivityHistorySection.js +11 -7
  8. package/dist/modules/customers/components/detail/ActivityHistorySection.js.map +2 -2
  9. package/dist/modules/dictionaries/components/DictionariesManager.js +75 -33
  10. package/dist/modules/dictionaries/components/DictionariesManager.js.map +2 -2
  11. package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js +95 -43
  12. package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js.map +2 -2
  13. package/dist/modules/dictionaries/components/DictionarySelectControl.js +40 -19
  14. package/dist/modules/dictionaries/components/DictionarySelectControl.js.map +2 -2
  15. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +81 -37
  16. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  17. package/dist/modules/integrations/backend/integrations/page.js +45 -24
  18. package/dist/modules/integrations/backend/integrations/page.js.map +2 -2
  19. package/dist/modules/notifications/api/[id]/action/route.js +18 -3
  20. package/dist/modules/notifications/api/[id]/action/route.js.map +2 -2
  21. package/dist/modules/notifications/api/[id]/restore/route.js +19 -3
  22. package/dist/modules/notifications/api/[id]/restore/route.js.map +2 -2
  23. package/dist/modules/notifications/api/mark-all-read/route.js +18 -4
  24. package/dist/modules/notifications/api/mark-all-read/route.js.map +2 -2
  25. package/dist/modules/notifications/api/route.js +17 -4
  26. package/dist/modules/notifications/api/route.js.map +2 -2
  27. package/dist/modules/notifications/api/settings/route.js +26 -5
  28. package/dist/modules/notifications/api/settings/route.js.map +2 -2
  29. package/dist/modules/notifications/lib/routeHelpers.js +64 -5
  30. package/dist/modules/notifications/lib/routeHelpers.js.map +2 -2
  31. package/package.json +7 -7
  32. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +58 -48
  33. package/src/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.tsx +14 -13
  34. package/src/modules/customers/components/detail/ActivitiesSection.tsx +22 -13
  35. package/src/modules/customers/components/detail/ActivityHistorySection.tsx +13 -7
  36. package/src/modules/dictionaries/components/DictionariesManager.tsx +82 -34
  37. package/src/modules/dictionaries/components/DictionaryEntriesEditor.tsx +99 -42
  38. package/src/modules/dictionaries/components/DictionarySelectControl.tsx +43 -18
  39. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +78 -35
  40. package/src/modules/integrations/backend/integrations/page.tsx +42 -21
  41. package/src/modules/notifications/api/[id]/action/route.ts +17 -2
  42. package/src/modules/notifications/api/[id]/restore/route.ts +19 -3
  43. package/src/modules/notifications/api/mark-all-read/route.ts +18 -4
  44. package/src/modules/notifications/api/route.ts +16 -3
  45. package/src/modules/notifications/api/settings/route.ts +26 -5
  46. package/src/modules/notifications/lib/routeHelpers.ts +101 -4
@@ -235,8 +235,15 @@ export default function EditVariantPage({ params }: { params?: { productId?: str
235
235
  : currentProductId
236
236
  if (resolvedProductId) setCurrentProductId(resolvedProductId)
237
237
  const metadata = typeof record.metadata === 'object' && record.metadata ? { ...(record.metadata as Record<string, unknown>) } : {}
238
- const attachments = await fetchVariantAttachments(variantId!)
239
- const priceDrafts = await loadVariantPrices(variantId!, priceKinds)
238
+ const [attachments, priceDrafts, productRes] = await Promise.all([
239
+ fetchVariantAttachments(variantId!),
240
+ loadVariantPrices(variantId!, priceKinds),
241
+ resolvedProductId
242
+ ? apiCall<ProductResponse>(
243
+ `/api/catalog/products?id=${encodeURIComponent(resolvedProductId)}&page=1&pageSize=1`,
244
+ )
245
+ : Promise.resolve(null),
246
+ ])
240
247
  const priceIdMap: Record<string, string> = {}
241
248
  const priceVersionMap: Record<string, string | null> = {}
242
249
  Object.entries(priceDrafts).forEach(([kindId, draft]) => {
@@ -250,10 +257,7 @@ export default function EditVariantPage({ params }: { params?: { productId?: str
250
257
  const customDefaults = extractCustomFieldEntries(record)
251
258
  let loadedOptionDefinitions: OptionDefinition[] = []
252
259
  if (resolvedProductId) {
253
- const productRes = await apiCall<ProductResponse>(
254
- `/api/catalog/products?id=${encodeURIComponent(resolvedProductId)}&page=1&pageSize=1`,
255
- )
256
- if (productRes.ok) {
260
+ if (productRes?.ok) {
257
261
  const product = Array.isArray(productRes.result?.items) ? productRes.result?.items?.[0] : undefined
258
262
  if (product) {
259
263
  setProductTitle(typeof product.title === 'string' ? product.title : '')
@@ -311,12 +315,9 @@ export default function EditVariantPage({ params }: { params?: { productId?: str
311
315
  : typeof (record as any).taxRateId === 'string'
312
316
  ? (record as any).taxRateId
313
317
  : null
314
- if (variantTaxRateId) {
315
- const selectedVariantTaxRate = await fetchTaxRateById(variantTaxRateId).catch(() => null)
316
- if (selectedVariantTaxRate && !cancelled) {
317
- setTaxRates((current) => mergeTaxRateSummaries(current, selectedVariantTaxRate))
318
- }
319
- }
318
+ // The variant's own tax rate is hydrated into the select options by the
319
+ // dedicated tax-rate effect (it watches initialValues.taxRateId), so it no
320
+ // longer needs a serial fetch inside the primary loader.
320
321
  if (!cancelled) {
321
322
  const optionValues =
322
323
  typeof record.option_values === 'object' && record.option_values
@@ -393,7 +394,7 @@ export default function EditVariantPage({ params }: { params?: { productId?: str
393
394
  }
394
395
  load()
395
396
  return () => { cancelled = true }
396
- }, [variantId, t, currentProductId, fetchTaxRateById, priceKinds])
397
+ }, [variantId, t, currentProductId, priceKinds])
397
398
 
398
399
  const groups = React.useMemo<CrudFormGroup[]>(() => {
399
400
  const list: CrudFormGroup[] = [
@@ -204,21 +204,30 @@ export function ActivitiesSection({
204
204
  return
205
205
  }
206
206
 
207
- // In legacy mode, also fetch legacy activities and merge with canonical
207
+ // In legacy mode, also fetch legacy activities and merge with canonical.
208
+ // Legacy fallback uses known page numbers, so request every page up front
209
+ // and resolve them together instead of awaiting each one sequentially.
210
+ const legacyPageNumbers = Array.from({ length: loadedPages }, (_, index) => index + 1)
211
+ const legacyPayloads = await Promise.all(
212
+ legacyPageNumbers.map((legacyPage) => {
213
+ const legacyParams = new URLSearchParams({
214
+ entityId,
215
+ page: String(legacyPage),
216
+ pageSize: '50',
217
+ sortField: 'occurredAt',
218
+ sortDir: 'desc',
219
+ })
220
+ if (dealId) legacyParams.set('dealId', dealId)
221
+ return readApiResultOrThrow<{ items?: ActivitySummary[]; totalPages?: number }>(
222
+ `/api/customers/activities?${legacyParams.toString()}`,
223
+ ).catch(() => ({ items: [] as ActivitySummary[], totalPages: 1 }))
224
+ }),
225
+ )
226
+ // Merge in page order so timeline ordering stays stable regardless of
227
+ // which request settles first.
208
228
  const legacyItems: InteractionSummary[] = []
209
229
  let legacyTotalPages = 1
210
- for (let legacyPage = 1; legacyPage <= loadedPages; legacyPage += 1) {
211
- const legacyParams = new URLSearchParams({
212
- entityId,
213
- page: String(legacyPage),
214
- pageSize: '50',
215
- sortField: 'occurredAt',
216
- sortDir: 'desc',
217
- })
218
- if (dealId) legacyParams.set('dealId', dealId)
219
- const legacyPayload = await readApiResultOrThrow<{ items?: ActivitySummary[]; totalPages?: number }>(
220
- `/api/customers/activities?${legacyParams.toString()}`,
221
- ).catch(() => ({ items: [] as ActivitySummary[], totalPages: 1 }))
230
+ for (const legacyPayload of legacyPayloads) {
222
231
  legacyItems.push(...(Array.isArray(legacyPayload?.items) ? legacyPayload.items.map(normalizeLegacyActivity) : []))
223
232
  legacyTotalPages = typeof legacyPayload?.totalPages === 'number' ? legacyPayload.totalPages : legacyTotalPages
224
233
  }
@@ -265,17 +265,23 @@ export function ActivityHistorySection({
265
265
  let combined = canonicalItems
266
266
 
267
267
  if (!useCanonicalInteractions) {
268
+ // Legacy fallback uses known page numbers, so request every page in
269
+ // parallel (sharing the abort signal) and merge them in page order.
270
+ const legacyPageNumbers = Array.from({ length: loadedPages }, (_, index) => index + 1)
271
+ const legacyPayloads = await Promise.all(
272
+ legacyPageNumbers.map((legacyPage) =>
273
+ readApiResultOrThrow<{ items?: ActivitySummary[]; totalPages?: number }>(
274
+ `/api/customers/activities?entityId=${encodeURIComponent(entityId)}&page=${legacyPage}&pageSize=20&sortField=occurredAt&sortDir=desc`,
275
+ { signal },
276
+ ).catch(() => ({ items: [] as ActivitySummary[], totalPages: 1 })),
277
+ ),
278
+ )
279
+ if (isStale()) return
268
280
  const legacyItems: InteractionSummary[] = []
269
281
  let legacyTotalPages = 1
270
- for (let legacyPage = 1; legacyPage <= loadedPages; legacyPage += 1) {
271
- const legacyPayload = await readApiResultOrThrow<{ items?: ActivitySummary[]; totalPages?: number }>(
272
- `/api/customers/activities?entityId=${encodeURIComponent(entityId)}&page=${legacyPage}&pageSize=20&sortField=occurredAt&sortDir=desc`,
273
- { signal },
274
- ).catch(() => ({ items: [] as ActivitySummary[], totalPages: 1 }))
275
- if (isStale()) return
282
+ for (const legacyPayload of legacyPayloads) {
276
283
  legacyItems.push(...(Array.isArray(legacyPayload.items) ? legacyPayload.items.map(normalizeLegacyActivity) : []))
277
284
  legacyTotalPages = typeof legacyPayload.totalPages === 'number' ? legacyPayload.totalPages : legacyTotalPages
278
- if (legacyPage >= legacyTotalPages) break
279
285
  }
280
286
  const rangeStartDate = computeRangeStart(dateRange)
281
287
  const filteredLegacy = legacyItems.filter((item) => {
@@ -18,6 +18,7 @@ import { flash } from '@open-mercato/ui/backend/FlashMessages'
18
18
  import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
19
19
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
20
20
  import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
21
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
21
22
  import { useT } from '@open-mercato/shared/lib/i18n/context'
22
23
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
23
24
  import { DictionaryEntriesEditor } from './DictionaryEntriesEditor'
@@ -71,6 +72,15 @@ export function DictionariesManager() {
71
72
  const [submitting, setSubmitting] = React.useState(false)
72
73
  const [deleting, setDeleting] = React.useState<string | null>(null)
73
74
  const selectionFromQueryApplied = React.useRef(false)
75
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
76
+ formId: string
77
+ resourceKind: string
78
+ resourceId?: string
79
+ retryLastMutation: () => Promise<boolean>
80
+ }>({
81
+ contextId: 'dictionaries:dictionary',
82
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
83
+ })
74
84
  const inheritedManageMessage = t('dictionaries.config.error.inheritedManage', 'Inherited dictionaries must be managed at the parent organization.')
75
85
  const requestedDictionaryId = searchParams?.get('dictionaryId') ?? null
76
86
  const requestedDictionaryKey = searchParams?.get('key')?.trim().toLowerCase() ?? null
@@ -226,31 +236,56 @@ export function DictionariesManager() {
226
236
  entrySortMode: form.entrySortMode,
227
237
  }
228
238
  if (dialog.mode === 'create') {
229
- const call = await apiCall<Record<string, unknown>>('/api/dictionaries', {
230
- method: 'POST',
231
- headers: { 'content-type': 'application/json' },
232
- body: JSON.stringify(payload),
239
+ await runMutation({
240
+ operation: async () => {
241
+ const call = await apiCall<Record<string, unknown>>('/api/dictionaries', {
242
+ method: 'POST',
243
+ headers: { 'content-type': 'application/json' },
244
+ body: JSON.stringify(payload),
245
+ })
246
+ if (!call.ok) {
247
+ throw new Error(typeof call.result?.error === 'string' ? call.result.error : 'Failed to create dictionary')
248
+ }
249
+ return call
250
+ },
251
+ context: {
252
+ formId: 'dictionaries:dictionary',
253
+ resourceKind: 'dictionaries.dictionary',
254
+ retryLastMutation,
255
+ },
256
+ mutationPayload: payload,
233
257
  })
234
- if (!call.ok) {
235
- throw new Error(typeof call.result?.error === 'string' ? call.result.error : 'Failed to create dictionary')
236
- }
237
258
  flash(t('dictionaries.config.success.create', 'Dictionary created.'), 'success')
238
259
  } else if (dialog.dictionary) {
239
- const call = await withScopedApiRequestHeaders(
240
- buildOptimisticLockHeader(dialog.dictionary.updatedAt),
241
- () =>
242
- apiCall<Record<string, unknown>>(`/api/dictionaries/${dialog.dictionary!.id}`, {
243
- method: 'PATCH',
244
- headers: { 'content-type': 'application/json' },
245
- body: JSON.stringify(payload),
246
- }),
247
- )
248
- if (!call.ok) {
249
- throw Object.assign(
250
- new Error(typeof call.result?.error === 'string' ? call.result.error : 'Failed to update dictionary'),
251
- { status: call.status, ...(call.result && typeof call.result === 'object' ? call.result : {}) },
252
- )
253
- }
260
+ const dictionaryId = dialog.dictionary.id
261
+ const expectedUpdatedAt = dialog.dictionary.updatedAt
262
+ await runMutation({
263
+ operation: () =>
264
+ withScopedApiRequestHeaders(
265
+ buildOptimisticLockHeader(expectedUpdatedAt),
266
+ async () => {
267
+ const call = await apiCall<Record<string, unknown>>(`/api/dictionaries/${dictionaryId}`, {
268
+ method: 'PATCH',
269
+ headers: { 'content-type': 'application/json' },
270
+ body: JSON.stringify(payload),
271
+ })
272
+ if (!call.ok) {
273
+ throw Object.assign(
274
+ new Error(typeof call.result?.error === 'string' ? call.result.error : 'Failed to update dictionary'),
275
+ { status: call.status, ...(call.result && typeof call.result === 'object' ? call.result : {}) },
276
+ )
277
+ }
278
+ return call
279
+ },
280
+ ),
281
+ context: {
282
+ formId: 'dictionaries:dictionary',
283
+ resourceKind: 'dictionaries.dictionary',
284
+ resourceId: dictionaryId,
285
+ retryLastMutation,
286
+ },
287
+ mutationPayload: payload,
288
+ })
254
289
  flash(t('dictionaries.config.success.update', 'Dictionary updated.'), 'success')
255
290
  }
256
291
  closeDialog()
@@ -265,7 +300,7 @@ export function DictionariesManager() {
265
300
  } finally {
266
301
  setSubmitting(false)
267
302
  }
268
- }, [closeDialog, dialog, form.description, form.entrySortMode, form.key, form.name, inheritedManageMessage, loadDictionaries, t])
303
+ }, [closeDialog, dialog, form.description, form.entrySortMode, form.key, form.name, inheritedManageMessage, loadDictionaries, runMutation, retryLastMutation, t])
269
304
 
270
305
  const handleDelete = React.useCallback(
271
306
  async (dictionary: DictionarySummary) => {
@@ -288,16 +323,29 @@ export function DictionariesManager() {
288
323
  if (!confirmed) return
289
324
  setDeleting(dictionary.id)
290
325
  try {
291
- const call = await withScopedApiRequestHeaders(
292
- buildOptimisticLockHeader(dictionary.updatedAt),
293
- () => apiCall<Record<string, unknown>>(`/api/dictionaries/${dictionary.id}`, { method: 'DELETE' }),
294
- )
295
- if (!call.ok) {
296
- throw Object.assign(
297
- new Error(typeof call.result?.error === 'string' ? call.result.error : 'Failed to delete dictionary'),
298
- { status: call.status, ...(call.result && typeof call.result === 'object' ? call.result : {}) },
299
- )
300
- }
326
+ await runMutation({
327
+ operation: () =>
328
+ withScopedApiRequestHeaders(
329
+ buildOptimisticLockHeader(dictionary.updatedAt),
330
+ async () => {
331
+ const call = await apiCall<Record<string, unknown>>(`/api/dictionaries/${dictionary.id}`, { method: 'DELETE' })
332
+ if (!call.ok) {
333
+ throw Object.assign(
334
+ new Error(typeof call.result?.error === 'string' ? call.result.error : 'Failed to delete dictionary'),
335
+ { status: call.status, ...(call.result && typeof call.result === 'object' ? call.result : {}) },
336
+ )
337
+ }
338
+ return call
339
+ },
340
+ ),
341
+ context: {
342
+ formId: 'dictionaries:dictionary',
343
+ resourceKind: 'dictionaries.dictionary',
344
+ resourceId: dictionary.id,
345
+ retryLastMutation,
346
+ },
347
+ mutationPayload: { id: dictionary.id },
348
+ })
301
349
  flash(t('dictionaries.config.success.delete', 'Dictionary deleted.'), 'success')
302
350
  await loadDictionaries()
303
351
  } catch (err) {
@@ -310,7 +358,7 @@ export function DictionariesManager() {
310
358
  setDeleting(null)
311
359
  }
312
360
  },
313
- [confirm, inheritedManageMessage, loadDictionaries, t],
361
+ [confirm, inheritedManageMessage, loadDictionaries, runMutation, retryLastMutation, t],
314
362
  )
315
363
 
316
364
  const selectedDictionary = items.find((item) => item.id === selectedId) ?? null
@@ -17,6 +17,7 @@ import { flash } from '@open-mercato/ui/backend/FlashMessages'
17
17
  import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
18
18
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
19
19
  import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
20
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
20
21
  import { useQueryClient } from '@tanstack/react-query'
21
22
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
22
23
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -75,6 +76,19 @@ export function DictionaryEntriesEditor({ dictionaryId, dictionaryName, readOnly
75
76
  const [errors, setErrors] = React.useState<{ value?: string; label?: string }>({})
76
77
  const [translateEntry, setTranslateEntry] = React.useState<Entry | null>(null)
77
78
  const appearance = useAppearanceState(formState.icon, formState.color)
79
+ const mutationContextId = React.useMemo(
80
+ () => `dictionaries:dictionary_entry:${dictionaryId}`,
81
+ [dictionaryId],
82
+ )
83
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
84
+ formId: string
85
+ resourceKind: string
86
+ resourceId?: string
87
+ retryLastMutation: () => Promise<boolean>
88
+ }>({
89
+ contextId: mutationContextId,
90
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
91
+ })
78
92
 
79
93
  const resetForm = React.useCallback(() => {
80
94
  setFormState({ value: '', label: '', color: null, icon: null })
@@ -140,39 +154,64 @@ export function DictionaryEntriesEditor({ dictionaryId, dictionaryName, readOnly
140
154
  icon: appearance.icon,
141
155
  }
142
156
  if (formState.id) {
143
- const call = await withScopedApiRequestHeaders(
144
- buildOptimisticLockHeader(formState.updatedAt),
145
- () =>
146
- apiCall<Record<string, unknown>>(
147
- `/api/dictionaries/${dictionaryId}/entries/${formState.id}`,
148
- {
149
- method: 'PATCH',
150
- headers: { 'content-type': 'application/json' },
151
- body: JSON.stringify(payload),
157
+ const entryId = formState.id
158
+ const expectedUpdatedAt = formState.updatedAt
159
+ await runMutation({
160
+ operation: () =>
161
+ withScopedApiRequestHeaders(
162
+ buildOptimisticLockHeader(expectedUpdatedAt),
163
+ async () => {
164
+ const call = await apiCall<Record<string, unknown>>(
165
+ `/api/dictionaries/${dictionaryId}/entries/${entryId}`,
166
+ {
167
+ method: 'PATCH',
168
+ headers: { 'content-type': 'application/json' },
169
+ body: JSON.stringify(payload),
170
+ },
171
+ )
172
+ if (!call.ok) {
173
+ throw Object.assign(
174
+ new Error(typeof call.result?.error === 'string' ? call.result.error : 'Failed to save dictionary entry'),
175
+ { status: call.status, ...(call.result && typeof call.result === 'object' ? call.result : {}) },
176
+ )
177
+ }
178
+ return call
152
179
  },
153
180
  ),
154
- )
155
- if (!call.ok) {
156
- throw Object.assign(
157
- new Error(typeof call.result?.error === 'string' ? call.result.error : 'Failed to save dictionary entry'),
158
- { status: call.status, ...(call.result && typeof call.result === 'object' ? call.result : {}) },
159
- )
160
- }
181
+ context: {
182
+ formId: mutationContextId,
183
+ resourceKind: 'dictionaries.dictionary_entry',
184
+ resourceId: entryId,
185
+ retryLastMutation,
186
+ },
187
+ mutationPayload: payload,
188
+ })
161
189
  flash(t('dictionaries.config.entries.success.update', 'Dictionary entry updated.'), 'success')
162
190
  } else {
163
- const call = await apiCall<Record<string, unknown>>(
164
- `/api/dictionaries/${dictionaryId}/entries`,
165
- {
166
- method: 'POST',
167
- headers: { 'content-type': 'application/json' },
168
- body: JSON.stringify(payload),
191
+ await runMutation({
192
+ operation: async () => {
193
+ const call = await apiCall<Record<string, unknown>>(
194
+ `/api/dictionaries/${dictionaryId}/entries`,
195
+ {
196
+ method: 'POST',
197
+ headers: { 'content-type': 'application/json' },
198
+ body: JSON.stringify(payload),
199
+ },
200
+ )
201
+ if (!call.ok) {
202
+ throw new Error(
203
+ typeof call.result?.error === 'string' ? call.result.error : 'Failed to create dictionary entry',
204
+ )
205
+ }
206
+ return call
169
207
  },
170
- )
171
- if (!call.ok) {
172
- throw new Error(
173
- typeof call.result?.error === 'string' ? call.result.error : 'Failed to create dictionary entry',
174
- )
175
- }
208
+ context: {
209
+ formId: mutationContextId,
210
+ resourceKind: 'dictionaries.dictionary_entry',
211
+ retryLastMutation,
212
+ },
213
+ mutationPayload: payload,
214
+ })
176
215
  flash(t('dictionaries.config.entries.success.create', 'Dictionary entry created.'), 'success')
177
216
  }
178
217
  await invalidateDictionaryEntries(queryClient, dictionaryId)
@@ -190,7 +229,7 @@ export function DictionaryEntriesEditor({ dictionaryId, dictionaryName, readOnly
190
229
  } finally {
191
230
  setIsSaving(false)
192
231
  }
193
- }, [appearance, dictionaryId, formState.id, formState.label, formState.updatedAt, formState.value, queryClient, readOnly, readOnlyMessage, t])
232
+ }, [appearance, dictionaryId, formState.id, formState.label, formState.updatedAt, formState.value, mutationContextId, queryClient, readOnly, readOnlyMessage, runMutation, retryLastMutation, t])
194
233
 
195
234
  const handleDelete = React.useCallback(
196
235
  async (entry: Entry) => {
@@ -206,29 +245,47 @@ export function DictionaryEntriesEditor({ dictionaryId, dictionaryName, readOnly
206
245
  if (!confirmDelete) return
207
246
  setIsDeleting(true)
208
247
  try {
209
- const call = await withScopedApiRequestHeaders(
210
- buildOptimisticLockHeader(entry.updatedAt),
211
- () =>
212
- apiCall<Record<string, unknown>>(
213
- `/api/dictionaries/${dictionaryId}/entries/${entry.id}`,
214
- { method: 'DELETE' },
248
+ await runMutation({
249
+ operation: () =>
250
+ withScopedApiRequestHeaders(
251
+ buildOptimisticLockHeader(entry.updatedAt),
252
+ async () => {
253
+ const call = await apiCall<Record<string, unknown>>(
254
+ `/api/dictionaries/${dictionaryId}/entries/${entry.id}`,
255
+ { method: 'DELETE' },
256
+ )
257
+ if (!call.ok) {
258
+ throw Object.assign(
259
+ new Error(
260
+ typeof call.result?.error === 'string' ? call.result.error : 'Failed to delete dictionary entry',
261
+ ),
262
+ { status: call.status, ...(call.result && typeof call.result === 'object' ? call.result : {}) },
263
+ )
264
+ }
265
+ return call
266
+ },
215
267
  ),
216
- )
217
- if (!call.ok) {
218
- throw new Error(
219
- typeof call.result?.error === 'string' ? call.result.error : 'Failed to delete dictionary entry',
220
- )
221
- }
268
+ context: {
269
+ formId: mutationContextId,
270
+ resourceKind: 'dictionaries.dictionary_entry',
271
+ resourceId: entry.id,
272
+ retryLastMutation,
273
+ },
274
+ mutationPayload: { id: entry.id },
275
+ })
222
276
  await invalidateDictionaryEntries(queryClient, dictionaryId)
223
277
  flash(t('dictionaries.config.entries.success.delete', 'Dictionary entry deleted.'), 'success')
224
278
  } catch (err) {
279
+ if (surfaceRecordConflict(err, t)) {
280
+ return
281
+ }
225
282
  console.error('Failed to delete dictionary entry', err)
226
283
  flash(t('dictionaries.config.entries.error.delete', 'Failed to delete dictionary entry.'), 'error')
227
284
  } finally {
228
285
  setIsDeleting(false)
229
286
  }
230
287
  },
231
- [confirm, dictionaryId, queryClient, readOnly, readOnlyMessage, t],
288
+ [confirm, dictionaryId, mutationContextId, queryClient, readOnly, readOnlyMessage, runMutation, retryLastMutation, t],
232
289
  )
233
290
 
234
291
  const tableContent = React.useMemo(() => {
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
5
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
5
6
  import { useQueryClient } from '@tanstack/react-query'
6
7
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
7
8
  import { useT } from '@open-mercato/shared/lib/i18n/context'
@@ -35,6 +36,18 @@ export function DictionarySelectControl({
35
36
  const t = useT()
36
37
  const queryClient = useQueryClient()
37
38
  const scopeVersion = useOrganizationScopeVersion()
39
+ const mutationContextId = React.useMemo(
40
+ () => `dictionaries:dictionary_entry:inline:${dictionaryId}`,
41
+ [dictionaryId],
42
+ )
43
+ const { runMutation, retryLastMutation } = useGuardedMutation<{
44
+ formId: string
45
+ resourceKind: string
46
+ retryLastMutation: () => Promise<boolean>
47
+ }>({
48
+ contextId: mutationContextId,
49
+ blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
50
+ })
38
51
  const [inlineCreateEnabled, setInlineCreateEnabled] = React.useState<boolean>(allowInlineCreate)
39
52
 
40
53
  React.useEffect(() => {
@@ -110,24 +123,36 @@ export function DictionarySelectControl({
110
123
 
111
124
  const createOption = React.useCallback(
112
125
  async (input: { value: string; label?: string; color?: string | null; icon?: string | null }) => {
113
- const call = await apiCall<Record<string, unknown>>(
114
- `/api/dictionaries/${dictionaryId}/entries`,
115
- {
116
- method: 'POST',
117
- headers: { 'content-type': 'application/json' },
118
- body: JSON.stringify({
119
- value: input.value,
120
- label: input.label ?? input.value,
121
- color: input.color,
122
- icon: input.icon,
123
- }),
124
- },
125
- )
126
- if (!call.ok) {
127
- throw new Error(
128
- typeof call.result?.error === 'string' ? call.result.error : 'Failed to create dictionary entry',
129
- )
126
+ const payload = {
127
+ value: input.value,
128
+ label: input.label ?? input.value,
129
+ color: input.color,
130
+ icon: input.icon,
130
131
  }
132
+ const call = await runMutation({
133
+ operation: async () => {
134
+ const result = await apiCall<Record<string, unknown>>(
135
+ `/api/dictionaries/${dictionaryId}/entries`,
136
+ {
137
+ method: 'POST',
138
+ headers: { 'content-type': 'application/json' },
139
+ body: JSON.stringify(payload),
140
+ },
141
+ )
142
+ if (!result.ok) {
143
+ throw new Error(
144
+ typeof result.result?.error === 'string' ? result.result.error : 'Failed to create dictionary entry',
145
+ )
146
+ }
147
+ return result
148
+ },
149
+ context: {
150
+ formId: mutationContextId,
151
+ resourceKind: 'dictionaries.dictionary_entry',
152
+ retryLastMutation,
153
+ },
154
+ mutationPayload: payload,
155
+ })
131
156
  await invalidateDictionaryEntries(queryClient, dictionaryId)
132
157
  return {
133
158
  value: String(call.result?.value ?? input.value),
@@ -139,7 +164,7 @@ export function DictionarySelectControl({
139
164
  icon: typeof call.result?.icon === 'string' ? call.result.icon : null,
140
165
  }
141
166
  },
142
- [dictionaryId, queryClient],
167
+ [dictionaryId, mutationContextId, queryClient, runMutation, retryLastMutation],
143
168
  )
144
169
 
145
170
  const labels = React.useMemo(