@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.
- 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 +82 -37
- 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 +89 -38
- 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
|
|
@@ -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
|
-
|
|
125
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
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(() => {
|