@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/audit_logs/api/audit-logs/actions/route.js +12 -26
- package/dist/modules/audit_logs/api/audit-logs/actions/route.js.map +2 -2
- package/dist/modules/audit_logs/services/actionLogService.js +10 -4
- package/dist/modules/audit_logs/services/actionLogService.js.map +2 -2
- package/dist/modules/customers/components/detail/CompanyKpiBar.js +3 -2
- package/dist/modules/customers/components/detail/CompanyKpiBar.js.map +2 -2
- package/dist/modules/query_index/lib/coverage.js +36 -19
- package/dist/modules/query_index/lib/coverage.js.map +2 -2
- package/dist/modules/warranty_claims/backend/components/ReceivingPanel.js +5 -2
- package/dist/modules/warranty_claims/backend/components/ReceivingPanel.js.map +2 -2
- package/dist/modules/warranty_claims/backend/components/ReturnLabelPanel.js +1 -1
- package/dist/modules/warranty_claims/backend/components/ReturnLabelPanel.js.map +2 -2
- package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js +44 -15
- package/dist/modules/warranty_claims/backend/warranty_claims/[id]/page.js.map +2 -2
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js +31 -5
- package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.js.map +3 -3
- package/dist/modules/warranty_claims/lib/claimLines.js +20 -0
- package/dist/modules/warranty_claims/lib/claimLines.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/audit_logs/api/audit-logs/actions/route.ts +18 -28
- package/src/modules/audit_logs/services/actionLogService.ts +20 -18
- package/src/modules/customers/components/detail/CompanyKpiBar.tsx +2 -1
- package/src/modules/query_index/lib/coverage.ts +54 -31
- package/src/modules/warranty_claims/backend/components/ReceivingPanel.tsx +6 -3
- package/src/modules/warranty_claims/backend/components/ReturnLabelPanel.tsx +2 -2
- package/src/modules/warranty_claims/backend/warranty_claims/[id]/page.tsx +42 -16
- package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/[id]/page.tsx +33 -5
- 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
|
|
422
|
+
return actionLogListQuerySchema.parse({
|
|
413
423
|
...query,
|
|
414
424
|
})
|
|
415
425
|
}
|
|
416
426
|
|
|
417
|
-
private resolveActorUserIds(parsed:
|
|
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:
|
|
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:
|
|
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:
|
|
440
|
-
const pageSize =
|
|
441
|
-
|
|
442
|
-
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
|
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 }))
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|