@open-mercato/core 0.6.6-develop.6201.1.8ceb502c4b → 0.6.6-develop.6205.1.109e4b6a84

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 (22) 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 +82 -37
  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/package.json +7 -7
  16. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +58 -48
  17. package/src/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.tsx +14 -13
  18. package/src/modules/customers/components/detail/ActivitiesSection.tsx +22 -13
  19. package/src/modules/customers/components/detail/ActivityHistorySection.tsx +13 -7
  20. package/src/modules/dictionaries/components/DictionariesManager.tsx +89 -38
  21. package/src/modules/dictionaries/components/DictionaryEntriesEditor.tsx +99 -42
  22. package/src/modules/dictionaries/components/DictionarySelectControl.tsx +43 -18
@@ -459,45 +459,54 @@ export default function EditCatalogProductPage({
459
459
  setVariantMediaGroups([]);
460
460
  return;
461
461
  }
462
- const CONCURRENCY = 5;
463
- const groups: VariantMediaGroup[] = [];
464
- for (let i = 0; i < mapped.length; i += CONCURRENCY) {
465
- const batch = mapped.slice(i, i + CONCURRENCY);
466
- const results = await Promise.all(
467
- batch.map(async (variant) => {
468
- try {
469
- const res = await apiCall<AttachmentListResponse>(
470
- `/api/attachments?entityId=${encodeURIComponent(E.catalog.catalog_product_variant)}&recordId=${encodeURIComponent(variant.id)}`,
471
- );
472
- if (!res.ok) return null;
473
- const mediaItems: ProductMediaItem[] = (res.result?.items ?? []).map(
474
- (item) => ({
475
- id: item.id,
476
- url: item.url,
477
- fileName: item.fileName,
478
- fileSize: item.fileSize,
479
- thumbnailUrl: item.thumbnailUrl ?? undefined,
480
- }),
481
- );
482
- if (!mediaItems.length) return null;
483
- return {
484
- variantId: variant.id,
485
- variantName: variant.name,
486
- defaultMediaId: variant.defaultMediaId,
487
- items: mediaItems,
488
- editUrl: `/backend/catalog/products/${id}/variants/${variant.id}`,
489
- } satisfies VariantMediaGroup;
490
- } catch {
491
- // Non-critical: variant media is optional; gallery degrades gracefully
492
- return null;
462
+ // Load per-variant media in the background so the product form is not blocked
463
+ // by the batched attachment fetches; the readonly gallery fills in once ready.
464
+ void (async () => {
465
+ try {
466
+ const CONCURRENCY = 5;
467
+ const groups: VariantMediaGroup[] = [];
468
+ for (let i = 0; i < mapped.length; i += CONCURRENCY) {
469
+ const batch = mapped.slice(i, i + CONCURRENCY);
470
+ const results = await Promise.all(
471
+ batch.map(async (variant) => {
472
+ try {
473
+ const res = await apiCall<AttachmentListResponse>(
474
+ `/api/attachments?entityId=${encodeURIComponent(E.catalog.catalog_product_variant)}&recordId=${encodeURIComponent(variant.id)}`,
475
+ );
476
+ if (!res.ok) return null;
477
+ const mediaItems: ProductMediaItem[] = (res.result?.items ?? []).map(
478
+ (item) => ({
479
+ id: item.id,
480
+ url: item.url,
481
+ fileName: item.fileName,
482
+ fileSize: item.fileSize,
483
+ thumbnailUrl: item.thumbnailUrl ?? undefined,
484
+ }),
485
+ );
486
+ if (!mediaItems.length) return null;
487
+ return {
488
+ variantId: variant.id,
489
+ variantName: variant.name,
490
+ defaultMediaId: variant.defaultMediaId,
491
+ items: mediaItems,
492
+ editUrl: `/backend/catalog/products/${id}/variants/${variant.id}`,
493
+ } satisfies VariantMediaGroup;
494
+ } catch {
495
+ // Non-critical: variant media is optional; gallery degrades gracefully
496
+ return null;
497
+ }
498
+ }),
499
+ );
500
+ for (const result of results) {
501
+ if (result) groups.push(result);
493
502
  }
494
- }),
495
- );
496
- for (const result of results) {
497
- if (result) groups.push(result);
503
+ }
504
+ setVariantMediaGroups(groups);
505
+ } catch (err) {
506
+ console.error("catalog.variants.media.fetch failed", err);
507
+ setVariantMediaGroups([]);
498
508
  }
499
- }
500
- setVariantMediaGroups(groups);
509
+ })();
501
510
  } catch (err) {
502
511
  console.error("catalog.variants.fetch failed", err);
503
512
  setVariants([]);
@@ -635,9 +644,18 @@ export default function EditCatalogProductPage({
635
644
  : typeof record.taxRateId === "string"
636
645
  ? record.taxRateId
637
646
  : null;
638
- const optionSchemaTemplate = optionSchemaId
639
- ? await fetchOptionSchemaTemplate(optionSchemaId)
640
- : null;
647
+ const [optionSchemaTemplate, attachments, conversionsRes] =
648
+ await Promise.all([
649
+ optionSchemaId
650
+ ? fetchOptionSchemaTemplate(optionSchemaId)
651
+ : Promise.resolve(null),
652
+ fetchAttachments(productId!),
653
+ apiCall<{ items?: Array<Record<string, unknown>> }>(
654
+ `/api/catalog/product-unit-conversions?productId=${encodeURIComponent(productId!)}&page=1&pageSize=100`,
655
+ undefined,
656
+ { fallback: { items: [] } },
657
+ ),
658
+ ]);
641
659
  const normalizedSchema = normalizeOptionSchemaRecord(
642
660
  optionSchemaTemplate?.schema,
643
661
  );
@@ -647,14 +665,6 @@ export default function EditCatalogProductPage({
647
665
  if (!optionInputs.length) {
648
666
  optionInputs = readOptionSchema(metadata);
649
667
  }
650
- const [attachments, conversionsRes] = await Promise.all([
651
- fetchAttachments(productId!),
652
- apiCall<{ items?: Array<Record<string, unknown>> }>(
653
- `/api/catalog/product-unit-conversions?productId=${encodeURIComponent(productId!)}&page=1&pageSize=100`,
654
- undefined,
655
- { fallback: { items: [] } },
656
- ),
657
- ]);
658
668
  const conversionRows = conversionsRes.ok
659
669
  ? readProductConversionRows(conversionsRes.result?.items)
660
670
  : [];
@@ -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
@@ -121,16 +131,19 @@ export function DictionariesManager() {
121
131
  : []
122
132
  const filtered = list.filter((dictionary: DictionarySummary) => dictionary.managerVisibility !== 'hidden')
123
133
  setItems(filtered)
124
- if (!filtered.find((dict: DictionarySummary) => dict.id === selectedId)) {
125
- setSelectedId(filtered.length ? filtered[0].id : null)
126
- }
134
+ setSelectedId((current) => {
135
+ if (current && filtered.some((dict: DictionarySummary) => dict.id === current)) {
136
+ return current
137
+ }
138
+ return filtered.length ? filtered[0].id : null
139
+ })
127
140
  } catch (err) {
128
141
  console.error('Failed to load dictionaries', err)
129
142
  flash(t('dictionaries.config.error.load', 'Failed to load dictionaries.'), 'error')
130
143
  } finally {
131
144
  setLoading(false)
132
145
  }
133
- }, [selectedId, t])
146
+ }, [t])
134
147
 
135
148
  React.useEffect(() => {
136
149
  loadDictionaries().catch(() => {})
@@ -226,31 +239,56 @@ export function DictionariesManager() {
226
239
  entrySortMode: form.entrySortMode,
227
240
  }
228
241
  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),
242
+ await runMutation({
243
+ operation: async () => {
244
+ const call = await apiCall<Record<string, unknown>>('/api/dictionaries', {
245
+ method: 'POST',
246
+ headers: { 'content-type': 'application/json' },
247
+ body: JSON.stringify(payload),
248
+ })
249
+ if (!call.ok) {
250
+ throw new Error(typeof call.result?.error === 'string' ? call.result.error : 'Failed to create dictionary')
251
+ }
252
+ return call
253
+ },
254
+ context: {
255
+ formId: 'dictionaries:dictionary',
256
+ resourceKind: 'dictionaries.dictionary',
257
+ retryLastMutation,
258
+ },
259
+ mutationPayload: payload,
233
260
  })
234
- if (!call.ok) {
235
- throw new Error(typeof call.result?.error === 'string' ? call.result.error : 'Failed to create dictionary')
236
- }
237
261
  flash(t('dictionaries.config.success.create', 'Dictionary created.'), 'success')
238
262
  } 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
- }
263
+ const dictionaryId = dialog.dictionary.id
264
+ const expectedUpdatedAt = dialog.dictionary.updatedAt
265
+ await runMutation({
266
+ operation: () =>
267
+ withScopedApiRequestHeaders(
268
+ buildOptimisticLockHeader(expectedUpdatedAt),
269
+ async () => {
270
+ const call = await apiCall<Record<string, unknown>>(`/api/dictionaries/${dictionaryId}`, {
271
+ method: 'PATCH',
272
+ headers: { 'content-type': 'application/json' },
273
+ body: JSON.stringify(payload),
274
+ })
275
+ if (!call.ok) {
276
+ throw Object.assign(
277
+ new Error(typeof call.result?.error === 'string' ? call.result.error : 'Failed to update dictionary'),
278
+ { status: call.status, ...(call.result && typeof call.result === 'object' ? call.result : {}) },
279
+ )
280
+ }
281
+ return call
282
+ },
283
+ ),
284
+ context: {
285
+ formId: 'dictionaries:dictionary',
286
+ resourceKind: 'dictionaries.dictionary',
287
+ resourceId: dictionaryId,
288
+ retryLastMutation,
289
+ },
290
+ mutationPayload: payload,
291
+ })
254
292
  flash(t('dictionaries.config.success.update', 'Dictionary updated.'), 'success')
255
293
  }
256
294
  closeDialog()
@@ -265,7 +303,7 @@ export function DictionariesManager() {
265
303
  } finally {
266
304
  setSubmitting(false)
267
305
  }
268
- }, [closeDialog, dialog, form.description, form.entrySortMode, form.key, form.name, inheritedManageMessage, loadDictionaries, t])
306
+ }, [closeDialog, dialog, form.description, form.entrySortMode, form.key, form.name, inheritedManageMessage, loadDictionaries, runMutation, retryLastMutation, t])
269
307
 
270
308
  const handleDelete = React.useCallback(
271
309
  async (dictionary: DictionarySummary) => {
@@ -288,16 +326,29 @@ export function DictionariesManager() {
288
326
  if (!confirmed) return
289
327
  setDeleting(dictionary.id)
290
328
  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
- }
329
+ await runMutation({
330
+ operation: () =>
331
+ withScopedApiRequestHeaders(
332
+ buildOptimisticLockHeader(dictionary.updatedAt),
333
+ async () => {
334
+ const call = await apiCall<Record<string, unknown>>(`/api/dictionaries/${dictionary.id}`, { method: 'DELETE' })
335
+ if (!call.ok) {
336
+ throw Object.assign(
337
+ new Error(typeof call.result?.error === 'string' ? call.result.error : 'Failed to delete dictionary'),
338
+ { status: call.status, ...(call.result && typeof call.result === 'object' ? call.result : {}) },
339
+ )
340
+ }
341
+ return call
342
+ },
343
+ ),
344
+ context: {
345
+ formId: 'dictionaries:dictionary',
346
+ resourceKind: 'dictionaries.dictionary',
347
+ resourceId: dictionary.id,
348
+ retryLastMutation,
349
+ },
350
+ mutationPayload: { id: dictionary.id },
351
+ })
301
352
  flash(t('dictionaries.config.success.delete', 'Dictionary deleted.'), 'success')
302
353
  await loadDictionaries()
303
354
  } catch (err) {
@@ -310,7 +361,7 @@ export function DictionariesManager() {
310
361
  setDeleting(null)
311
362
  }
312
363
  },
313
- [confirm, inheritedManageMessage, loadDictionaries, t],
364
+ [confirm, inheritedManageMessage, loadDictionaries, runMutation, retryLastMutation, t],
314
365
  )
315
366
 
316
367
  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(() => {