@open-mercato/core 0.6.6-develop.6201.1.8ceb502c4b → 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.
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js +52 -45
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js +9 -13
- package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivitiesSection.js +17 -12
- package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityHistorySection.js +11 -7
- package/dist/modules/customers/components/detail/ActivityHistorySection.js.map +2 -2
- package/dist/modules/dictionaries/components/DictionariesManager.js +75 -33
- package/dist/modules/dictionaries/components/DictionariesManager.js.map +2 -2
- package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js +95 -43
- package/dist/modules/dictionaries/components/DictionaryEntriesEditor.js.map +2 -2
- package/dist/modules/dictionaries/components/DictionarySelectControl.js +40 -19
- package/dist/modules/dictionaries/components/DictionarySelectControl.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +58 -48
- package/src/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.tsx +14 -13
- package/src/modules/customers/components/detail/ActivitiesSection.tsx +22 -13
- package/src/modules/customers/components/detail/ActivityHistorySection.tsx +13 -7
- package/src/modules/dictionaries/components/DictionariesManager.tsx +82 -34
- package/src/modules/dictionaries/components/DictionaryEntriesEditor.tsx +99 -42
- 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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
)
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
497
|
-
|
|
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 =
|
|
639
|
-
|
|
640
|
-
|
|
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
|
: [];
|
package/src/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.tsx
CHANGED
|
@@ -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
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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,
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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(() => {
|