@open-mercato/core 0.10.1-develop.7879.1.80d3740285 → 0.10.1-develop.7888.1.96c8c2bd5f

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 (43) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/attachments/lib/transferClient.js +72 -0
  3. package/dist/modules/attachments/lib/transferClient.js.map +7 -0
  4. package/dist/modules/attachments/lib/transferLimits.js +5 -0
  5. package/dist/modules/attachments/lib/transferLimits.js.map +7 -0
  6. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/create/page.js +23 -26
  7. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/create/page.js.map +2 -2
  8. package/dist/modules/catalog/backend/catalog/products/create/page.js +31 -23
  9. package/dist/modules/catalog/backend/catalog/products/create/page.js.map +2 -2
  10. package/dist/modules/catalog/components/products/mediaTransfer.js +27 -0
  11. package/dist/modules/catalog/components/products/mediaTransfer.js.map +7 -0
  12. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +5 -2
  13. package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +2 -2
  14. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +2 -1
  15. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  16. package/dist/modules/warranty_claims/backend/components/ReceivingPanel.js +5 -2
  17. package/dist/modules/warranty_claims/backend/components/ReceivingPanel.js.map +2 -2
  18. package/dist/modules/warranty_claims/backend/components/ReturnLabelPanel.js +1 -1
  19. package/dist/modules/warranty_claims/backend/components/ReturnLabelPanel.js.map +2 -2
  20. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js +44 -15
  21. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js.map +2 -2
  22. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js +31 -5
  23. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js.map +3 -3
  24. package/dist/modules/warranty_claims/lib/claimLines.js +20 -0
  25. package/dist/modules/warranty_claims/lib/claimLines.js.map +7 -0
  26. package/package.json +7 -7
  27. package/src/modules/attachments/lib/transferClient.ts +87 -0
  28. package/src/modules/attachments/lib/transferLimits.ts +1 -0
  29. package/src/modules/catalog/backend/catalog/products/[productId]/variants/create/page.tsx +23 -32
  30. package/src/modules/catalog/backend/catalog/products/create/page.tsx +31 -28
  31. package/src/modules/catalog/components/products/mediaTransfer.ts +40 -0
  32. package/src/modules/catalog/i18n/de.json +2 -0
  33. package/src/modules/catalog/i18n/en.json +2 -0
  34. package/src/modules/catalog/i18n/es.json +2 -0
  35. package/src/modules/catalog/i18n/ko.json +2 -0
  36. package/src/modules/catalog/i18n/pl.json +2 -0
  37. package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +6 -2
  38. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +3 -2
  39. package/src/modules/warranty_claims/backend/components/ReceivingPanel.tsx +6 -3
  40. package/src/modules/warranty_claims/backend/components/ReturnLabelPanel.tsx +2 -2
  41. package/src/modules/warranty_claims/backend/warranty_claims/[id]/page.tsx +42 -16
  42. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.tsx +33 -5
  43. package/src/modules/warranty_claims/lib/claimLines.ts +22 -0
@@ -35,10 +35,10 @@ import {
35
35
  VariantPricesSection,
36
36
  VariantMediaSection,
37
37
  } from '@open-mercato/core/modules/catalog/components/products/VariantBuilder'
38
- import type { ProductMediaItem } from '@open-mercato/core/modules/catalog/components/products/ProductMediaManager'
39
38
  import { buildAttachmentImageUrl, slugifyAttachmentFileName } from '@open-mercato/core/modules/attachments/lib/imageUrls'
40
39
  import { fetchOptionSchemaTemplate } from '../../../optionSchemaClient'
41
40
  import { createLogger } from '@open-mercato/shared/lib/logger'
41
+ import { transferDraftMedia } from '@open-mercato/core/modules/catalog/components/products/mediaTransfer'
42
42
 
43
43
  const logger = createLogger('catalog')
44
44
 
@@ -393,11 +393,19 @@ export default function CreateVariantPage({ params }: { params?: { productId?: s
393
393
  if (!variantId) {
394
394
  throw createCrudFormError(t('catalog.variants.form.errors.idMissing', 'Variant id missing after create.'))
395
395
  }
396
- await transferVariantMedia({
396
+ const mediaTransfer = await transferDraftMedia({
397
+ entityId: E.catalog.catalog_product_variant,
397
398
  draftId: values.mediaDraftId,
398
- variantId,
399
+ recordId: variantId,
399
400
  mediaItems: Array.isArray(values.mediaItems) ? values.mediaItems : [],
400
401
  })
402
+ if (mediaTransfer.failedCount > 0) {
403
+ logger.error('attachments.transfer.failed', {
404
+ failed: mediaTransfer.failedCount,
405
+ requested: mediaTransfer.requestedCount,
406
+ errors: mediaTransfer.errors,
407
+ })
408
+ }
401
409
  await syncVariantPrices({
402
410
  priceKinds,
403
411
  priceDrafts: values.prices ?? {},
@@ -408,7 +416,18 @@ export default function CreateVariantPage({ params }: { params?: { productId?: s
408
416
  productTaxRateId,
409
417
  productTaxRate,
410
418
  })
411
- flash(t('catalog.variants.form.createSuccess', 'Variant created.'), 'success')
419
+ if (mediaTransfer.failedCount > 0) {
420
+ flash(
421
+ t(
422
+ 'catalog.variants.form.mediaTransferIncomplete',
423
+ 'Variant created, but {failed} of {total} media files could not be attached.',
424
+ { failed: mediaTransfer.failedCount, total: mediaTransfer.requestedCount },
425
+ ),
426
+ 'warning',
427
+ )
428
+ } else {
429
+ flash(t('catalog.variants.form.createSuccess', 'Variant created.'), 'success')
430
+ }
412
431
  router.push(`/backend/catalog/products/${productId}#variants`)
413
432
  }}
414
433
  />
@@ -417,34 +436,6 @@ export default function CreateVariantPage({ params }: { params?: { productId?: s
417
436
  )
418
437
  }
419
438
 
420
- async function transferVariantMedia({
421
- draftId,
422
- variantId,
423
- mediaItems,
424
- }: {
425
- draftId?: string | null
426
- variantId: string
427
- mediaItems: ProductMediaItem[]
428
- }): Promise<void> {
429
- if (!draftId || !variantId) return
430
- const attachmentIds = mediaItems.map((item) => item.id).filter((id): id is string => typeof id === 'string' && id.length > 0)
431
- if (!attachmentIds.length) return
432
- await apiCall(
433
- '/api/attachments/transfer',
434
- {
435
- method: 'POST',
436
- headers: { 'content-type': 'application/json' },
437
- body: JSON.stringify({
438
- entityId: E.catalog.catalog_product_variant,
439
- attachmentIds,
440
- fromRecordId: draftId,
441
- toRecordId: variantId,
442
- }),
443
- },
444
- { fallback: null },
445
- )
446
- }
447
-
448
439
  async function syncVariantPrices({
449
440
  priceKinds,
450
441
  priceDrafts,
@@ -98,6 +98,7 @@ import {
98
98
  type ProductUnitConversionInput,
99
99
  } from "@open-mercato/core/modules/catalog/components/products/productFormUtils";
100
100
  import { createLogger } from '@open-mercato/shared/lib/logger'
101
+ import { transferDraftMedia } from "@open-mercato/core/modules/catalog/components/products/mediaTransfer";
101
102
 
102
103
  const logger = createLogger('catalog')
103
104
 
@@ -419,9 +420,6 @@ export default function CreateCatalogProductPage() {
419
420
  const mediaItems = Array.isArray(formValues.mediaItems)
420
421
  ? formValues.mediaItems
421
422
  : [];
422
- const attachmentIds = mediaItems
423
- .map((item) => (typeof item.id === "string" ? item.id : null))
424
- .filter((value): value is string => !!value);
425
423
  const mediaDraftId =
426
424
  typeof formValues.mediaDraftId === "string"
427
425
  ? formValues.mediaDraftId
@@ -768,27 +766,18 @@ export default function CreateCatalogProductPage() {
768
766
  await createCrud("catalog/prices", pricePayload);
769
767
  }
770
768
 
771
- if (mediaDraftId && attachmentIds.length) {
772
- const transfer = await apiCall<{
773
- ok?: boolean;
774
- error?: string;
775
- }>(
776
- "/api/attachments/transfer",
777
- {
778
- method: "POST",
779
- headers: { "content-type": "application/json" },
780
- body: JSON.stringify({
781
- entityId: E.catalog.catalog_product,
782
- attachmentIds,
783
- fromRecordId: mediaDraftId,
784
- toRecordId: productId,
785
- }),
786
- },
787
- { fallback: null },
788
- );
789
- if (!transfer.ok) {
790
- logger.error("attachments.transfer.failed", { err: transfer.result?.error });
791
- }
769
+ const mediaTransfer = await transferDraftMedia({
770
+ entityId: E.catalog.catalog_product,
771
+ draftId: mediaDraftId,
772
+ recordId: productId,
773
+ mediaItems,
774
+ });
775
+ if (mediaTransfer.failedCount > 0) {
776
+ logger.error("attachments.transfer.failed", {
777
+ failed: mediaTransfer.failedCount,
778
+ requested: mediaTransfer.requestedCount,
779
+ errors: mediaTransfer.errors,
780
+ });
792
781
  }
793
782
 
794
783
  if (inboxDraft) {
@@ -817,10 +806,24 @@ export default function CreateCatalogProductPage() {
817
806
  }
818
807
  }
819
808
 
820
- flash(
821
- t("catalog.products.create.success", "Product created."),
822
- "success",
823
- );
809
+ if (mediaTransfer.failedCount > 0) {
810
+ flash(
811
+ t(
812
+ "catalog.products.create.mediaTransferIncomplete",
813
+ "Product created, but {failed} of {total} media files could not be attached.",
814
+ {
815
+ failed: mediaTransfer.failedCount,
816
+ total: mediaTransfer.requestedCount,
817
+ },
818
+ ),
819
+ "warning",
820
+ );
821
+ } else {
822
+ flash(
823
+ t("catalog.products.create.success", "Product created."),
824
+ "success",
825
+ );
826
+ }
824
827
  if (inboxDraft) {
825
828
  router.push(
826
829
  `/backend/inbox-ops/proposals/${encodeURIComponent(inboxDraft.proposalId)}`,
@@ -0,0 +1,40 @@
1
+ import { transferAttachmentsInChunks } from '@open-mercato/core/modules/attachments/lib/transferClient'
2
+ import type { ProductMediaItem } from './ProductMediaManager'
3
+
4
+ export type DraftMediaTransferInput = {
5
+ entityId: string
6
+ draftId?: string | null
7
+ recordId: string
8
+ mediaItems: ProductMediaItem[]
9
+ }
10
+
11
+ export type DraftMediaTransferResult = {
12
+ requestedCount: number
13
+ failedCount: number
14
+ errors: string[]
15
+ }
16
+
17
+ export async function transferDraftMedia({
18
+ entityId,
19
+ draftId,
20
+ recordId,
21
+ mediaItems,
22
+ }: DraftMediaTransferInput): Promise<DraftMediaTransferResult> {
23
+ const attachmentIds = mediaItems
24
+ .map((item) => item.id)
25
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
26
+ if (!draftId || !recordId || !attachmentIds.length) {
27
+ return { requestedCount: 0, failedCount: 0, errors: [] }
28
+ }
29
+ const result = await transferAttachmentsInChunks({
30
+ entityId,
31
+ attachmentIds,
32
+ fromRecordId: draftId,
33
+ toRecordId: recordId,
34
+ })
35
+ return {
36
+ requestedCount: attachmentIds.length,
37
+ failedCount: result.failedIds.length + result.unconfirmedCount,
38
+ errors: result.errors,
39
+ }
40
+ }
@@ -416,6 +416,7 @@
416
416
  "catalog.products.create.errors.variant": "Variante konnte nicht erstellt werden.",
417
417
  "catalog.products.create.fields.currency": "Hauptwährung",
418
418
  "catalog.products.create.handleHelp": "Handle wird für URLs verwendet und muss eindeutig sein.",
419
+ "catalog.products.create.mediaTransferIncomplete": "Produkt erstellt, aber {failed} von {total} Mediendateien konnten nicht angehängt werden.",
419
420
  "catalog.products.create.meta.description": "Verwalte Untertitel, Handle und Währung für deine Storefronts.",
420
421
  "catalog.products.create.meta.handleLabel": "Handle",
421
422
  "catalog.products.create.meta.title": "Produkt-Metadaten",
@@ -882,6 +883,7 @@
882
883
  "catalog.variants.form.isDefaultLabel": "Standardvariante",
883
884
  "catalog.variants.form.loading": "Formular wird geladen...",
884
885
  "catalog.variants.form.media": "Medien",
886
+ "catalog.variants.form.mediaTransferIncomplete": "Variante erstellt, aber {failed} von {total} Mediendateien konnten nicht angehängt werden.",
885
887
  "catalog.variants.form.nameLabel": "Name",
886
888
  "catalog.variants.form.namePlaceholder": "z. B. Blau / Klein",
887
889
  "catalog.variants.form.nameRequired": "Geben Sie den Variantennamen an.",
@@ -416,6 +416,7 @@
416
416
  "catalog.products.create.errors.variant": "Failed to create variant.",
417
417
  "catalog.products.create.fields.currency": "Primary currency",
418
418
  "catalog.products.create.handleHelp": "Handle is used for URLs and must be unique.",
419
+ "catalog.products.create.mediaTransferIncomplete": "Product created, but {failed} of {total} media files could not be attached.",
419
420
  "catalog.products.create.meta.description": "Manage subtitle, handle, and currency for storefronts.",
420
421
  "catalog.products.create.meta.handleLabel": "Handle",
421
422
  "catalog.products.create.meta.title": "Product meta",
@@ -882,6 +883,7 @@
882
883
  "catalog.variants.form.isDefaultLabel": "Default variant",
883
884
  "catalog.variants.form.loading": "Loading form...",
884
885
  "catalog.variants.form.media": "Media",
886
+ "catalog.variants.form.mediaTransferIncomplete": "Variant created, but {failed} of {total} media files could not be attached.",
885
887
  "catalog.variants.form.nameLabel": "Name",
886
888
  "catalog.variants.form.namePlaceholder": "e.g., Blue / Small",
887
889
  "catalog.variants.form.nameRequired": "Provide the variant name.",
@@ -416,6 +416,7 @@
416
416
  "catalog.products.create.errors.variant": "No se pudo crear la variante.",
417
417
  "catalog.products.create.fields.currency": "Moneda principal",
418
418
  "catalog.products.create.handleHelp": "El identificador se usa en URLs y debe ser único.",
419
+ "catalog.products.create.mediaTransferIncomplete": "Producto creado, pero no se pudieron adjuntar {failed} de {total} archivos multimedia.",
419
420
  "catalog.products.create.meta.description": "Administra el subtítulo, el identificador y la moneda para las vitrinas.",
420
421
  "catalog.products.create.meta.handleLabel": "Identificador",
421
422
  "catalog.products.create.meta.title": "Metadatos del producto",
@@ -882,6 +883,7 @@
882
883
  "catalog.variants.form.isDefaultLabel": "Variante predeterminada",
883
884
  "catalog.variants.form.loading": "Cargando formulario...",
884
885
  "catalog.variants.form.media": "Medios",
886
+ "catalog.variants.form.mediaTransferIncomplete": "Variante creada, pero no se pudieron adjuntar {failed} de {total} archivos multimedia.",
885
887
  "catalog.variants.form.nameLabel": "Nombre",
886
888
  "catalog.variants.form.namePlaceholder": "ej., Azul / Pequeño",
887
889
  "catalog.variants.form.nameRequired": "Ingrese el nombre de la variante.",
@@ -416,6 +416,7 @@
416
416
  "catalog.products.create.errors.variant": "변형 생성에 실패했습니다.",
417
417
  "catalog.products.create.fields.currency": "기본 통화",
418
418
  "catalog.products.create.handleHelp": "핸들은 URL에 사용되며 고유해야 합니다.",
419
+ "catalog.products.create.mediaTransferIncomplete": "상품이 생성되었지만 미디어 파일 {total}개 중 {failed}개를 첨부하지 못했습니다.",
419
420
  "catalog.products.create.meta.description": "스토어프론트에 사용할 부제목, 핸들, 통화를 관리합니다.",
420
421
  "catalog.products.create.meta.handleLabel": "핸들",
421
422
  "catalog.products.create.meta.title": "상품 메타 정보",
@@ -882,6 +883,7 @@
882
883
  "catalog.variants.form.isDefaultLabel": "기본 변형",
883
884
  "catalog.variants.form.loading": "폼을 불러오는 중...",
884
885
  "catalog.variants.form.media": "미디어",
886
+ "catalog.variants.form.mediaTransferIncomplete": "변형이 생성되었지만 미디어 파일 {total}개 중 {failed}개를 첨부하지 못했습니다.",
885
887
  "catalog.variants.form.nameLabel": "이름",
886
888
  "catalog.variants.form.namePlaceholder": "예: 블루 / 스몰",
887
889
  "catalog.variants.form.nameRequired": "변형 이름을 입력하세요.",
@@ -416,6 +416,7 @@
416
416
  "catalog.products.create.errors.variant": "Nie udało się utworzyć wariantu.",
417
417
  "catalog.products.create.fields.currency": "Główna waluta",
418
418
  "catalog.products.create.handleHelp": "Identyfikator jest używany w adresach URL i musi być unikalny.",
419
+ "catalog.products.create.mediaTransferIncomplete": "Produkt utworzony, ale nie udało się dołączyć {failed} z {total} plików multimedialnych.",
419
420
  "catalog.products.create.meta.description": "Zarządzaj podtytułem, identyfikatorem i walutą dla witryn.",
420
421
  "catalog.products.create.meta.handleLabel": "Identyfikator",
421
422
  "catalog.products.create.meta.title": "Metadane produktu",
@@ -882,6 +883,7 @@
882
883
  "catalog.variants.form.isDefaultLabel": "Domyślny wariant",
883
884
  "catalog.variants.form.loading": "Ładowanie formularza...",
884
885
  "catalog.variants.form.media": "Multimedia",
886
+ "catalog.variants.form.mediaTransferIncomplete": "Wariant utworzony, ale nie udało się dołączyć {failed} z {total} plików multimedialnych.",
885
887
  "catalog.variants.form.nameLabel": "Nazwa",
886
888
  "catalog.variants.form.namePlaceholder": "np. Niebieski / Mały",
887
889
  "catalog.variants.form.nameRequired": "Podaj nazwę wariantu.",
@@ -36,6 +36,7 @@ type LaneProps = {
36
36
  deals: DealCardData[]
37
37
  /** Server-side aggregate for accurate counts/totals across all deals in this stage, not just loaded ones */
38
38
  aggregate?: LaneAggregateProp | null
39
+ paginationTotal?: number
39
40
  selectedDealIds: Set<string>
40
41
  buildMenuItems: (deal: DealCardData) => RowActionItem[]
41
42
  activeDragDealId?: string | null
@@ -176,6 +177,7 @@ function LaneImpl({
176
177
  stage,
177
178
  deals,
178
179
  aggregate,
180
+ paginationTotal,
179
181
  selectedDealIds,
180
182
  buildMenuItems,
181
183
  activeDragDealId,
@@ -212,7 +214,8 @@ function LaneImpl({
212
214
  : null
213
215
  const visibleCount = deals.length
214
216
  const totalCount = stats.count
215
- const hasMoreDeals = totalCount > visibleCount
217
+ const remainingCount = (paginationTotal ?? totalCount) - visibleCount
218
+ const hasMoreDeals = remainingCount > 0
216
219
  const accentClass = getAccentClass(stage.tone)
217
220
  const countBadgeClass = getCountBadgeClass(stage.tone)
218
221
 
@@ -336,7 +339,7 @@ function LaneImpl({
336
339
  t,
337
340
  'customers.deals.kanban.lane.loadMore',
338
341
  'Show more ({remaining})',
339
- { remaining: totalCount - visibleCount },
342
+ { remaining: remainingCount },
340
343
  )}
341
344
  </DashedTileButton>
342
345
  ) : null}
@@ -358,6 +361,7 @@ export const Lane = React.memo(LaneImpl, (prev, next) => {
358
361
  if (prev.stage !== next.stage) return false
359
362
  if (prev.deals !== next.deals) return false
360
363
  if (prev.aggregate !== next.aggregate) return false
364
+ if (prev.paginationTotal !== next.paginationTotal) return false
361
365
  if (prev.selectedDealIds !== next.selectedDealIds) return false
362
366
  if (prev.buildMenuItems !== next.buildMenuItems) return false
363
367
  if (prev.activeDragDealId !== next.activeDragDealId) return false
@@ -631,7 +631,7 @@ export default function DealsKanbanPage(): React.ReactElement {
631
631
  )
632
632
 
633
633
  // Aggregate query — gives accurate per-stage counts + totals (in base currency) across the entire pipeline,
634
- // not just the loaded slice. Drives both lane headers and pagination decisions.
634
+ // not just the loaded slice. Drives lane headers and currency breakdowns.
635
635
  const aggregateQuery = useQuery<StageAggregateResponse | null>({
636
636
  queryKey: [
637
637
  'customers',
@@ -2853,7 +2853,7 @@ export default function DealsKanbanPage(): React.ReactElement {
2853
2853
  />
2854
2854
  ) : (
2855
2855
  <>
2856
- {lanes.map((stage) => {
2856
+ {lanes.map((stage, stageIndex) => {
2857
2857
  const laneDeals = sortedDealsByStage.get(stage.id) ?? EMPTY_DEAL_ARRAY
2858
2858
  return (
2859
2859
  <Lane
@@ -2861,6 +2861,7 @@ export default function DealsKanbanPage(): React.ReactElement {
2861
2861
  stage={stage}
2862
2862
  deals={laneDeals}
2863
2863
  aggregate={aggregateByStage.get(stage.id) ?? null}
2864
+ paginationTotal={laneQueries[stageIndex]?.data?.total ?? 0}
2864
2865
  selectedDealIds={selectedDealIds}
2865
2866
  buildMenuItems={buildMenuItems}
2866
2867
  activeDragDealId={activeDragDealId}
@@ -56,9 +56,9 @@ function readErrorKey(value: unknown): string | null {
56
56
  return typeof value.error === 'string' && value.error.trim().length ? value.error.trim() : null
57
57
  }
58
58
 
59
- function toApiError(status: number, result: unknown, fallbackKey: string, t: TranslateFn): Error & { status?: number } {
59
+ function toApiError(status: number, result: unknown, fallbackKey: string, t: TranslateFn): Error & { status: number; body: unknown } {
60
60
  const key = readErrorKey(result) ?? fallbackKey
61
- return Object.assign(new Error(t(key, key)), { status })
61
+ return Object.assign(new Error(t(key, key)), { status, body: result })
62
62
  }
63
63
 
64
64
  function formatLineTitle(line: ReceivingLine, t: TranslateFn): string {
@@ -142,7 +142,10 @@ export function ReceivingPanel({
142
142
  setDialogLine(null)
143
143
  await onRefresh()
144
144
  } catch (err) {
145
- if (surfaceRecordConflict(err, t, { onRefresh })) return
145
+ if (surfaceRecordConflict(err, t, { onRefresh })) {
146
+ setDialogLine(null)
147
+ return
148
+ }
146
149
  const message = err instanceof Error && err.message
147
150
  ? err.message
148
151
  : t('warranty_claims.receiving.error.save', 'Failed to save receiving details.')
@@ -55,9 +55,9 @@ function readErrorKey(value: unknown): string | null {
55
55
  return typeof value.error === 'string' && value.error.trim().length ? value.error.trim() : null
56
56
  }
57
57
 
58
- function toApiError(status: number, result: unknown, fallbackKey: string, t: TranslateFn): Error & { status?: number } {
58
+ function toApiError(status: number, result: unknown, fallbackKey: string, t: TranslateFn): Error & { status: number; body: unknown } {
59
59
  const key = readErrorKey(result) ?? fallbackKey
60
- return Object.assign(new Error(t(key, key)), { status })
60
+ return Object.assign(new Error(t(key, key)), { status, body: result })
61
61
  }
62
62
 
63
63
  function isReturnLabelStatus(value: unknown): value is ReturnLabelResponse {
@@ -9,6 +9,7 @@ import { hasFeature } from '@open-mercato/shared/security/features'
9
9
  import { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'
10
10
  import type { TranslateFn, TranslateParams } from '@open-mercato/shared/lib/i18n/context'
11
11
  import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
12
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
12
13
  import { formatDateTime, formatRelativeTime } from '@open-mercato/shared/lib/time'
13
14
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
14
15
  import { DataTable } from '@open-mercato/ui/backend/DataTable'
@@ -67,6 +68,7 @@ import {
67
68
  import { AiAssessButtons } from '../../components/AiAssessButtons'
68
69
  import { useUserDisplayNames } from '../../components/useUserDisplayNames'
69
70
  import { ReceivingPanel } from '../../components/ReceivingPanel'
71
+ import { loadClaimLines } from '../../../lib/claimLines'
70
72
  import { ReturnLabelPanel } from '../../components/ReturnLabelPanel'
71
73
  import { VendorRecoverySuggestionsPanel } from '../../components/VendorRecoverySuggestionsPanel'
72
74
  import { ClaimStageProgress } from '../../components/ClaimStageProgress'
@@ -929,11 +931,7 @@ export default function WarrantyClaimDetailPage({ params }: { params?: { id?: st
929
931
  undefined,
930
932
  { fallback: { items: [] }, errorMessage: t('warranty_claims.detail.error.load') },
931
933
  ),
932
- readApiResultOrThrow<{ items?: unknown[] }>(
933
- `/api/warranty_claims/lines?claimId=${encodeURIComponent(id)}&page=1&pageSize=100`,
934
- undefined,
935
- { fallback: { items: [] }, errorMessage: t('warranty_claims.detail.error.loadLines') },
936
- ),
934
+ loadClaimLines(id, t('warranty_claims.detail.error.loadLines')),
937
935
  readApiResultOrThrow<{ items?: unknown[] }>(
938
936
  `/api/warranty_claims/events?claimId=${encodeURIComponent(id)}`,
939
937
  undefined,
@@ -1427,6 +1425,39 @@ export default function WarrantyClaimDetailPage({ params }: { params?: { id?: st
1427
1425
  }
1428
1426
  }, [claim, confirm, currentStatus, featureAccess.claimDelete, loadData, mutationContext, router, runMutation, t])
1429
1427
 
1428
+ const deleteLine = React.useCallback(async (line: ClaimLine) => {
1429
+ if (!claim || !linesInlineEditable) return
1430
+ const confirmed = await confirm({
1431
+ title: t('warranty_claims.form.lines.remove'),
1432
+ variant: 'destructive',
1433
+ })
1434
+ if (!confirmed) return
1435
+ const payload = { id: line.id, claimId: claim.id }
1436
+ try {
1437
+ await runMutation({
1438
+ operation: () => withScopedApiRequestHeaders(
1439
+ buildOptimisticLockHeader(line.updatedAt),
1440
+ () => deleteCrud('warranty_claims/lines', {
1441
+ body: payload,
1442
+ errorMessage: t('warranty_claims.detail.lines.error.delete'),
1443
+ }),
1444
+ ),
1445
+ context: { ...mutationContext, resourceKind: 'warranty_claims.claim_line', resourceId: line.id },
1446
+ mutationPayload: payload,
1447
+ })
1448
+ flash(t('warranty_claims.detail.lines.flash.deleted'), 'success')
1449
+ await loadData()
1450
+ } catch (err) {
1451
+ if (surfaceRecordConflict(err, t, { onRefresh: loadData })) return
1452
+ getTelemetryRuntime()?.reportError(err, {
1453
+ module: 'warranty_claims',
1454
+ code: 'warranty_claims.line_delete_failed',
1455
+ attributes: { claimId: claim.id, lineId: line.id },
1456
+ })
1457
+ flash(translateErrorMessage(err, t, 'warranty_claims.detail.lines.error.delete'), 'error')
1458
+ }
1459
+ }, [claim, confirm, linesInlineEditable, loadData, mutationContext, runMutation, t])
1460
+
1430
1461
  const headerMoreActions = React.useMemo(() => {
1431
1462
  if (!claim) return []
1432
1463
  const items = [
@@ -2090,6 +2121,12 @@ export default function WarrantyClaimDetailPage({ params }: { params?: { id?: st
2090
2121
  label: t('warranty_claims.detail.lines.edit'),
2091
2122
  onSelect: () => setLineDialog({ mode: 'edit', line }),
2092
2123
  },
2124
+ {
2125
+ id: 'delete',
2126
+ label: t('warranty_claims.form.lines.remove'),
2127
+ destructive: true,
2128
+ onSelect: () => { void deleteLine(line) },
2129
+ },
2093
2130
  ]}
2094
2131
  />
2095
2132
  ) : undefined}
@@ -2533,17 +2570,6 @@ export default function WarrantyClaimDetailPage({ params }: { params?: { id?: st
2533
2570
  setLineDialog(null)
2534
2571
  await loadData()
2535
2572
  }}
2536
- onDelete={lineDialog?.mode === 'edit'
2537
- ? async () => {
2538
- await deleteCrud('warranty_claims/lines', {
2539
- body: { id: lineDialog.line.id, claimId: claim.id },
2540
- errorMessage: t('warranty_claims.detail.lines.error.delete'),
2541
- })
2542
- flash(t('warranty_claims.detail.lines.flash.deleted'), 'success')
2543
- setLineDialog(null)
2544
- await loadData()
2545
- }
2546
- : undefined}
2547
2573
  />
2548
2574
  </DialogContent>
2549
2575
  </Dialog>
@@ -5,6 +5,7 @@ import Link from 'next/link'
5
5
  import { ArrowLeft, Check, CircleAlert, Info, Paperclip, RefreshCw, ShieldCheck, Trash2, TriangleAlert } from 'lucide-react'
6
6
  import { replaceWithPageReload } from '@open-mercato/core/modules/portal/lib/navigation'
7
7
  import { useLocale, useT, type TranslateFn } from '@open-mercato/shared/lib/i18n/context'
8
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
8
9
  import { localizeDictionaryLabel } from '@open-mercato/core/modules/warranty_claims/lib/dictionaryLabels'
9
10
  import { formatQuantity } from '@open-mercato/core/modules/warranty_claims/lib/quantity'
10
11
  import {
@@ -30,6 +31,7 @@ import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/u
30
31
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
31
32
  import { usePortalContext } from '@open-mercato/ui/portal/PortalContext'
32
33
  import { PortalEmptyState } from '@open-mercato/ui/portal/components/PortalEmptyState'
34
+ import { usePortalAppEvent } from '@open-mercato/ui/portal/hooks/usePortalAppEvent'
33
35
 
34
36
  type Props = { params: { orgSlug: string; id: string } }
35
37
 
@@ -357,6 +359,8 @@ export default function WarrantyClaimPortalDetailPage({ params }: Props) {
357
359
  const [actionSubmitting, setActionSubmitting] = React.useState(false)
358
360
  const replacementInputRef = React.useRef<HTMLInputElement | null>(null)
359
361
  const replacementTargetRef = React.useRef<PortalAttachment | null>(null)
362
+ const claimRequestSequenceRef = React.useRef(0)
363
+ const eventsRequestSequenceRef = React.useRef(0)
360
364
 
361
365
  // Leaving an authenticated page for the login page crosses the public/authenticated
362
366
  // boundary, so it must be a full page load — see `replaceWithPageReload`.
@@ -367,8 +371,11 @@ export default function WarrantyClaimPortalDetailPage({ params }: Props) {
367
371
  }, [loading, user, params.orgSlug])
368
372
 
369
373
  const refreshEvents = React.useCallback(async (claimId: string) => {
374
+ const requestSequence = ++eventsRequestSequenceRef.current
370
375
  const result = await apiCall<EventResponse>(`/api/warranty_claims/portal/events?claimId=${encodeURIComponent(claimId)}`)
371
- if (result.ok && result.result) setEvents(result.result.items)
376
+ if (requestSequence === eventsRequestSequenceRef.current && result.ok && result.result) {
377
+ setEvents(result.result.items)
378
+ }
372
379
  }, [])
373
380
 
374
381
  const refreshAttachments = React.useCallback(async (claimId: string) => {
@@ -376,12 +383,14 @@ export default function WarrantyClaimPortalDetailPage({ params }: Props) {
376
383
  if (result.ok && result.result) setAttachments(result.result.items)
377
384
  }, [])
378
385
 
379
- const loadClaim = React.useCallback(async () => {
380
- setIsLoading(true)
386
+ const loadClaim = React.useCallback(async (showLoading = true) => {
387
+ const requestSequence = ++claimRequestSequenceRef.current
388
+ if (showLoading) setIsLoading(true)
381
389
  setError(null)
382
390
  setNotFound(false)
383
391
  try {
384
392
  const detail = await apiCall<ClaimResponse>(`/api/warranty_claims/portal/claims/${encodeURIComponent(params.id)}`)
393
+ if (requestSequence !== claimRequestSequenceRef.current) return
385
394
  if (detail.status === 404) {
386
395
  setClaim(null)
387
396
  setNotFound(true)
@@ -397,9 +406,11 @@ export default function WarrantyClaimPortalDetailPage({ params }: Props) {
397
406
  refreshAttachments(detail.result.item.id),
398
407
  ])
399
408
  } catch {
400
- setError(t('warranty_claims.portal.detail.loadError'))
409
+ if (requestSequence === claimRequestSequenceRef.current) {
410
+ setError(t('warranty_claims.portal.detail.loadError'))
411
+ }
401
412
  } finally {
402
- setIsLoading(false)
413
+ if (requestSequence === claimRequestSequenceRef.current) setIsLoading(false)
403
414
  }
404
415
  }, [params.id, refreshAttachments, refreshEvents, t])
405
416
 
@@ -407,6 +418,23 @@ export default function WarrantyClaimPortalDetailPage({ params }: Props) {
407
418
  if (user) void loadClaim()
408
419
  }, [loadClaim, user])
409
420
 
421
+ usePortalAppEvent('warranty_claims.claim.portal_status_changed', (event) => {
422
+ if (!user || event.payload.claimId !== params.id) return
423
+ void loadClaim(false)
424
+ })
425
+
426
+ usePortalAppEvent('warranty_claims.claim.comment_added', (event) => {
427
+ if (!user || event.payload.claimId !== params.id) return
428
+ void refreshEvents(params.id).catch((error) => {
429
+ getTelemetryRuntime()?.reportError(error, {
430
+ module: 'warranty_claims',
431
+ code: 'warranty_claims.portal_timeline_refresh_failed',
432
+ attributes: { claimId: params.id },
433
+ })
434
+ setError(t('warranty_claims.portal.detail.loadError'))
435
+ })
436
+ })
437
+
410
438
  React.useEffect(() => {
411
439
  if (!user) return
412
440
  let cancelled = false
@@ -0,0 +1,22 @@
1
+ import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
2
+
3
+ type ClaimLinesPage = {
4
+ items?: unknown[]
5
+ totalPages?: number
6
+ }
7
+
8
+ export async function loadClaimLines(claimId: string, errorMessage: string): Promise<{ items: unknown[] }> {
9
+ const readPage = (page: number) => readApiResultOrThrow<ClaimLinesPage>(
10
+ `/api/warranty_claims/lines?claimId=${encodeURIComponent(claimId)}&page=${page}&pageSize=100&sortField=lineNo&sortDir=asc`,
11
+ undefined,
12
+ { fallback: { items: [] }, errorMessage },
13
+ )
14
+ const firstPage = await readPage(1)
15
+ const items = [...(firstPage.items ?? [])]
16
+ const totalPages = firstPage.totalPages ?? 1
17
+ for (let page = 2; page <= totalPages; page += 1) {
18
+ const payload = await readPage(page)
19
+ items.push(...(payload.items ?? []))
20
+ }
21
+ return { items }
22
+ }