@open-mercato/core 0.10.1-develop.7883.1.44ac2125f5 → 0.10.1-develop.7894.1.2c464c73e3

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 (29) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/audit_logs/api/audit-logs/actions/route.js +12 -26
  3. package/dist/modules/audit_logs/api/audit-logs/actions/route.js.map +2 -2
  4. package/dist/modules/audit_logs/services/actionLogService.js +10 -4
  5. package/dist/modules/audit_logs/services/actionLogService.js.map +2 -2
  6. package/dist/modules/customers/components/detail/CompanyKpiBar.js +3 -2
  7. package/dist/modules/customers/components/detail/CompanyKpiBar.js.map +2 -2
  8. package/dist/modules/query_index/lib/coverage.js +36 -19
  9. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  10. package/dist/modules/warranty_claims/backend/components/ReceivingPanel.js +5 -2
  11. package/dist/modules/warranty_claims/backend/components/ReceivingPanel.js.map +2 -2
  12. package/dist/modules/warranty_claims/backend/components/ReturnLabelPanel.js +1 -1
  13. package/dist/modules/warranty_claims/backend/components/ReturnLabelPanel.js.map +2 -2
  14. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js +44 -15
  15. package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js.map +2 -2
  16. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js +31 -5
  17. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js.map +3 -3
  18. package/dist/modules/warranty_claims/lib/claimLines.js +20 -0
  19. package/dist/modules/warranty_claims/lib/claimLines.js.map +7 -0
  20. package/package.json +7 -7
  21. package/src/modules/audit_logs/api/audit-logs/actions/route.ts +18 -28
  22. package/src/modules/audit_logs/services/actionLogService.ts +20 -18
  23. package/src/modules/customers/components/detail/CompanyKpiBar.tsx +2 -1
  24. package/src/modules/query_index/lib/coverage.ts +54 -31
  25. package/src/modules/warranty_claims/backend/components/ReceivingPanel.tsx +6 -3
  26. package/src/modules/warranty_claims/backend/components/ReturnLabelPanel.tsx +2 -2
  27. package/src/modules/warranty_claims/backend/warranty_claims/[id]/page.tsx +42 -16
  28. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.tsx +33 -5
  29. package/src/modules/warranty_claims/lib/claimLines.ts +22 -0
@@ -1,6 +1,7 @@
1
1
  import { raw, type FilterQuery } from '@mikro-orm/core'
2
2
  import type { EntityManager } from '@mikro-orm/postgresql'
3
3
  import { sql } from 'kysely'
4
+ import { z } from 'zod'
4
5
  import { ActionLog } from '@open-mercato/core/modules/audit_logs/data/entities'
5
6
  import {
6
7
  actionLogCreateSchema,
@@ -30,6 +31,15 @@ import {
30
31
 
31
32
  const logger = createLogger('audit_logs').child({ component: 'action-log-service' })
32
33
 
34
+ const DEFAULT_LIST_PAGE_SIZE = 50
35
+
36
+ const actionLogListQuerySchema = actionLogListSchema.extend({
37
+ page: z.number().int().positive().optional(),
38
+ pageSize: z.number().int().positive().max(200).optional(),
39
+ })
40
+
41
+ type ParsedActionLogListQuery = z.infer<typeof actionLogListQuerySchema>
42
+
33
43
  let validationWarningLogged = false
34
44
  let runtimeValidationAvailable: boolean | null = null
35
45
  let decryptionWarningLogged = false
@@ -409,26 +419,26 @@ export class ActionLogService {
409
419
  }
410
420
 
411
421
  private parseListQuery(query: Partial<ActionLogListQuery>) {
412
- return actionLogListSchema.parse({
422
+ return actionLogListQuerySchema.parse({
413
423
  ...query,
414
424
  })
415
425
  }
416
426
 
417
- private resolveActorUserIds(parsed: ActionLogListQuery): string[] {
427
+ private resolveActorUserIds(parsed: ParsedActionLogListQuery): string[] {
418
428
  const values = [...(parsed.actorUserIds ?? [])]
419
429
  if (parsed.actorUserId) values.push(parsed.actorUserId)
420
430
 
421
431
  return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
422
432
  }
423
433
 
424
- private resolveFieldNames(parsed: ActionLogListQuery): string[] {
434
+ private resolveFieldNames(parsed: ParsedActionLogListQuery): string[] {
425
435
  const values = [...(parsed.fieldNames ?? [])]
426
436
  if (parsed.fieldName) values.push(parsed.fieldName)
427
437
 
428
438
  return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
429
439
  }
430
440
 
431
- private resolveActionTypes(parsed: ActionLogListQuery): ActionLogFilterType[] {
441
+ private resolveActionTypes(parsed: ParsedActionLogListQuery): ActionLogFilterType[] {
432
442
  const values = [...(parsed.actionTypes ?? [])]
433
443
  if (parsed.actionType) values.push(parsed.actionType)
434
444
 
@@ -436,22 +446,14 @@ export class ActionLogService {
436
446
  .filter((value): value is ActionLogFilterType => ACTION_LOG_FILTER_TYPES.includes(value as ActionLogFilterType))
437
447
  }
438
448
 
439
- private resolvePagination(parsed: ActionLogListQuery): { page: number; pageSize: number; offset: number; limit: number } {
440
- const pageSize =
441
- typeof parsed.pageSize === 'number' && parsed.pageSize > 0
442
- ? parsed.pageSize
443
- : typeof parsed.limit === 'number' && parsed.limit > 0
444
- ? parsed.limit
445
- : 50
446
- const page = typeof parsed.page === 'number' && parsed.page > 0 ? parsed.page : 1
447
- const offset =
448
- typeof parsed.offset === 'number' && parsed.offset >= 0
449
- ? parsed.offset
450
- : (page - 1) * pageSize
449
+ private resolvePagination(parsed: ParsedActionLogListQuery): { page: number; pageSize: number; offset: number; limit: number } {
450
+ const pageSize = parsed.pageSize ?? parsed.limit ?? DEFAULT_LIST_PAGE_SIZE
451
+ const page = parsed.page ?? 1
452
+ const offset = parsed.offset ?? (page - 1) * pageSize
451
453
  return { page, pageSize, offset, limit: pageSize }
452
454
  }
453
455
 
454
- private async loadEntries(parsed: ActionLogListQuery, options?: { paginate?: boolean }) {
456
+ private async loadEntries(parsed: ParsedActionLogListQuery, options?: { paginate?: boolean }) {
455
457
  let query = (this.buildListQuery(parsed) as any).select('action_logs.id as id')
456
458
 
457
459
  if (options?.paginate !== false) {
@@ -480,7 +482,7 @@ export class ActionLogService {
480
482
  ))
481
483
  }
482
484
 
483
- private buildListQuery(parsed: ActionLogListQuery): any {
485
+ private buildListQuery(parsed: ParsedActionLogListQuery): any {
484
486
  let query = (this.em.getKysely<any>() as any)
485
487
  .selectFrom('action_logs')
486
488
  .selectAll()
@@ -167,13 +167,14 @@ export function CompanyKpiBar({ data }: CompanyKpiBarProps) {
167
167
  <div className="space-y-2">
168
168
  <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
169
169
  {visibleTiles.map((tile) => (
170
- <div key={tile.id} className="group relative">
170
+ <div key={tile.id} data-testid="company-kpi-tile" className="group relative flex">
171
171
  <KpiCard
172
172
  title={tile.title}
173
173
  value={tile.value}
174
174
  trend={tile.trend}
175
175
  formatValue={tile.formatValue}
176
176
  comparisonLabel={tile.comparisonLabel}
177
+ className="w-full"
177
178
  />
178
179
  <IconButton
179
180
  type="button"
@@ -191,7 +191,8 @@ async function updateCoverageRows(
191
191
  // overlapping adjustments compose, and drops the `SELECT` (and the window between it and the
192
192
  // write) that made the coverage row a contention point in the first place.
193
193
  //
194
- // In steady state that is one statement. The extra statements are recovery paths that do not
194
+ // For non-null tenants, steady state is one statement. NULL-tenant writers first take
195
+ // the scope advisory lock because the unique constraint cannot coordinate their rows. The extra statements are recovery paths that do not
195
196
  // fire once a scope has settled: the legacy NULL-organization fold below runs only for
196
197
  // global-organization scopes, the insert only until the scope's row exists, and the duplicate
197
198
  // prune only when the scope somehow holds more than one row.
@@ -209,6 +210,7 @@ async function incrementCoverageRow(
209
210
  scope: CoverageScope,
210
211
  deltas: { deltaBase: number; deltaIndex: number; deltaVector: number }
211
212
  ): Promise<void> {
213
+ if (scope.tenantId == null) await lockCoverageInitialization(db, scope)
212
214
  const storedOrgId = normalizeOrganizationForStore(scope.organizationId ?? null)
213
215
  let deltaBase = deltas.deltaBase
214
216
  let deltaIndex = deltas.deltaIndex
@@ -237,15 +239,7 @@ async function incrementCoverageRow(
237
239
  }
238
240
  }
239
241
 
240
- let updated = await updateCoverageRows(db, scope, { deltaBase, deltaIndex, deltaVector })
241
-
242
- if (updated.length === 0 && scope.tenantId == null) {
243
- // The unique constraint cannot serialize a first insert whose tenant is NULL. Take a
244
- // transaction-scoped lock only on this rowless branch, then repeat the UPDATE: a creator
245
- // that won while we waited has committed its row, so this adjustment composes into it.
246
- await lockCoverageInitialization(db, scope)
247
- updated = await updateCoverageRows(db, scope, { deltaBase, deltaIndex, deltaVector })
248
- }
242
+ const updated = await updateCoverageRows(db, scope, { deltaBase, deltaIndex, deltaVector })
249
243
 
250
244
  if (updated.length > 0) {
251
245
  // In steady state the scope holds exactly the row just incremented, so the adjustment is
@@ -257,7 +251,7 @@ async function incrementCoverageRow(
257
251
  }
258
252
 
259
253
  // No row for this scope yet. `ON CONFLICT` closes the insert race for scopes the unique
260
- // constraint can see. NULL-tenant initialization reaches here only while holding the scoped
254
+ // constraint can see. NULL-tenant writes reach here only while holding the scoped
261
255
  // transaction advisory lock above, so it has the same single-creator guarantee.
262
256
  const rows = await db
263
257
  .insertInto('entity_index_coverage' as any)
@@ -304,6 +298,27 @@ async function upsertCoverageRow(
304
298
  await purge.execute()
305
299
  }
306
300
 
301
+ if (scope.tenantId == null) {
302
+ const updated = await db
303
+ .updateTable('entity_index_coverage')
304
+ .set({
305
+ base_count: counts.baseCount,
306
+ indexed_count: counts.indexedCount,
307
+ vector_indexed_count: counts.vectorIndexedCount,
308
+ refreshed_at: sql`now()`,
309
+ })
310
+ .where('entity_type', '=', scope.entityType)
311
+ .where('tenant_id', 'is', null)
312
+ .where('organization_id', '=', storedOrgId)
313
+ .where('with_deleted', '=', scope.withDeleted === true)
314
+ .returning('id')
315
+ .execute() as Array<{ id: string }>
316
+ if (updated.length > 0) {
317
+ if (updated.length > 1) await pruneDuplicateCoverageRows(db, scope, updated[0]!.id)
318
+ return
319
+ }
320
+ }
321
+
307
322
  const rows = await db
308
323
  .insertInto('entity_index_coverage' as any)
309
324
  .values({
@@ -674,26 +689,34 @@ export async function writeCoverageCounts(
674
689
  const tenantId = scope.tenantId ?? null
675
690
  const organizationId = scope.organizationId ?? null
676
691
  const withDeleted = scope.withDeleted === true
677
- const existing = await fetchCoverageRow(db, {
678
- entityType,
679
- tenantId,
680
- organizationId,
681
- withDeleted,
682
- })
683
- const baseCount = counts.baseCount !== undefined
684
- ? Math.max(0, Math.trunc(toCount(counts.baseCount)))
685
- : Math.max(0, Math.trunc(toCount(existing?.base_count)))
686
- const indexCount = counts.indexedCount !== undefined
687
- ? Math.max(0, Math.trunc(toCount(counts.indexedCount)))
688
- : Math.max(0, Math.trunc(toCount(existing?.indexed_count)))
689
- const vectorCount = counts.vectorCount !== undefined
690
- ? Math.max(0, Math.trunc(toCount(counts.vectorCount)))
691
- : Math.max(0, Math.trunc(toCount(existing?.vector_indexed_count)))
692
- await upsertCoverageRow(db, { entityType, tenantId, organizationId, withDeleted }, {
693
- baseCount,
694
- indexedCount: indexCount,
695
- vectorIndexedCount: vectorCount,
696
- })
692
+ const write = async (executor: CoverageExecutor): Promise<void> => {
693
+ if (tenantId == null) await lockCoverageInitialization(executor, scope)
694
+ const existing = await fetchCoverageRow(executor, {
695
+ entityType,
696
+ tenantId,
697
+ organizationId,
698
+ withDeleted,
699
+ })
700
+ const baseCount = counts.baseCount !== undefined
701
+ ? Math.max(0, Math.trunc(toCount(counts.baseCount)))
702
+ : Math.max(0, Math.trunc(toCount(existing?.base_count)))
703
+ const indexCount = counts.indexedCount !== undefined
704
+ ? Math.max(0, Math.trunc(toCount(counts.indexedCount)))
705
+ : Math.max(0, Math.trunc(toCount(existing?.indexed_count)))
706
+ const vectorCount = counts.vectorCount !== undefined
707
+ ? Math.max(0, Math.trunc(toCount(counts.vectorCount)))
708
+ : Math.max(0, Math.trunc(toCount(existing?.vector_indexed_count)))
709
+ await upsertCoverageRow(executor, { entityType, tenantId, organizationId, withDeleted }, {
710
+ baseCount,
711
+ indexedCount: indexCount,
712
+ vectorIndexedCount: vectorCount,
713
+ })
714
+ }
715
+ if (tenantId == null && !db.isTransaction) {
716
+ await db.transaction().execute(write)
717
+ } else {
718
+ await write(db)
719
+ }
697
720
  }
698
721
 
699
722
  type AggregatedAdjustment = {
@@ -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
+ }