@open-mercato/core 0.6.2-develop.3330.1.8ad2b3b84c → 0.6.2-develop.3366.1.4692954e44

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 (102) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/sync_excel_upload/index.js +37 -0
  3. package/dist/generated/entities/sync_excel_upload/index.js.map +7 -0
  4. package/dist/generated/entities.ids.generated.js +4 -0
  5. package/dist/generated/entities.ids.generated.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +19 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/customers/search.js +3 -2
  9. package/dist/modules/customers/search.js.map +2 -2
  10. package/dist/modules/data_sync/api/runs/[id]/cancel.js +16 -0
  11. package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
  12. package/dist/modules/data_sync/di.js +2 -1
  13. package/dist/modules/data_sync/di.js.map +2 -2
  14. package/dist/modules/data_sync/lib/sync-engine.js +178 -54
  15. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  16. package/dist/modules/data_sync/lib/sync-run-service.js +23 -0
  17. package/dist/modules/data_sync/lib/sync-run-service.js.map +3 -3
  18. package/dist/modules/integrations/backend/integrations/[id]/page.js +241 -55
  19. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +3 -3
  20. package/dist/modules/sync_excel/acl.js +10 -0
  21. package/dist/modules/sync_excel/acl.js.map +7 -0
  22. package/dist/modules/sync_excel/api/import/route.js +164 -0
  23. package/dist/modules/sync_excel/api/import/route.js.map +7 -0
  24. package/dist/modules/sync_excel/api/preview/route.js +84 -0
  25. package/dist/modules/sync_excel/api/preview/route.js.map +7 -0
  26. package/dist/modules/sync_excel/api/upload/route.js +131 -0
  27. package/dist/modules/sync_excel/api/upload/route.js.map +7 -0
  28. package/dist/modules/sync_excel/data/entities.js +79 -0
  29. package/dist/modules/sync_excel/data/entities.js.map +7 -0
  30. package/dist/modules/sync_excel/data/validators.js +58 -0
  31. package/dist/modules/sync_excel/data/validators.js.map +7 -0
  32. package/dist/modules/sync_excel/di.js +11 -0
  33. package/dist/modules/sync_excel/di.js.map +7 -0
  34. package/dist/modules/sync_excel/index.js +11 -0
  35. package/dist/modules/sync_excel/index.js.map +7 -0
  36. package/dist/modules/sync_excel/integration.js +36 -0
  37. package/dist/modules/sync_excel/integration.js.map +7 -0
  38. package/dist/modules/sync_excel/lib/adapters/customers.js +852 -0
  39. package/dist/modules/sync_excel/lib/adapters/customers.js.map +7 -0
  40. package/dist/modules/sync_excel/lib/column-detector.js +77 -0
  41. package/dist/modules/sync_excel/lib/column-detector.js.map +7 -0
  42. package/dist/modules/sync_excel/lib/mapping.js +16 -0
  43. package/dist/modules/sync_excel/lib/mapping.js.map +7 -0
  44. package/dist/modules/sync_excel/lib/parser.js +125 -0
  45. package/dist/modules/sync_excel/lib/parser.js.map +7 -0
  46. package/dist/modules/sync_excel/lib/scope.js +44 -0
  47. package/dist/modules/sync_excel/lib/scope.js.map +7 -0
  48. package/dist/modules/sync_excel/lib/upload-storage.js +63 -0
  49. package/dist/modules/sync_excel/lib/upload-storage.js.map +7 -0
  50. package/dist/modules/sync_excel/migrations/Migration20260329181120_sync_excel.js +11 -0
  51. package/dist/modules/sync_excel/migrations/Migration20260329181120_sync_excel.js.map +7 -0
  52. package/dist/modules/sync_excel/setup.js +19 -0
  53. package/dist/modules/sync_excel/setup.js.map +7 -0
  54. package/dist/modules/sync_excel/widgets/injection/upload-config/target-options.js +293 -0
  55. package/dist/modules/sync_excel/widgets/injection/upload-config/target-options.js.map +7 -0
  56. package/dist/modules/sync_excel/widgets/injection/upload-config/widget.client.js +934 -0
  57. package/dist/modules/sync_excel/widgets/injection/upload-config/widget.client.js.map +7 -0
  58. package/dist/modules/sync_excel/widgets/injection/upload-config/widget.js +15 -0
  59. package/dist/modules/sync_excel/widgets/injection/upload-config/widget.js.map +7 -0
  60. package/dist/modules/sync_excel/widgets/injection-table.js +17 -0
  61. package/dist/modules/sync_excel/widgets/injection-table.js.map +7 -0
  62. package/generated/entities/sync_excel_upload/index.ts +17 -0
  63. package/generated/entities.ids.generated.ts +4 -0
  64. package/generated/entity-fields-registry.ts +19 -0
  65. package/package.json +7 -7
  66. package/src/modules/customers/search.ts +3 -2
  67. package/src/modules/data_sync/api/runs/[id]/cancel.ts +18 -0
  68. package/src/modules/data_sync/di.ts +4 -1
  69. package/src/modules/data_sync/lib/adapter.ts +15 -0
  70. package/src/modules/data_sync/lib/sync-engine.ts +205 -52
  71. package/src/modules/data_sync/lib/sync-run-service.ts +24 -0
  72. package/src/modules/integrations/backend/integrations/[id]/page.tsx +220 -15
  73. package/src/modules/integrations/i18n/de.json +9 -0
  74. package/src/modules/integrations/i18n/en.json +9 -0
  75. package/src/modules/integrations/i18n/es.json +9 -0
  76. package/src/modules/integrations/i18n/pl.json +9 -0
  77. package/src/modules/sync_excel/acl.ts +6 -0
  78. package/src/modules/sync_excel/api/import/route.ts +182 -0
  79. package/src/modules/sync_excel/api/preview/route.ts +89 -0
  80. package/src/modules/sync_excel/api/upload/route.ts +144 -0
  81. package/src/modules/sync_excel/data/entities.ts +61 -0
  82. package/src/modules/sync_excel/data/validators.ts +58 -0
  83. package/src/modules/sync_excel/di.ts +9 -0
  84. package/src/modules/sync_excel/i18n/de.json +107 -0
  85. package/src/modules/sync_excel/i18n/en.json +107 -0
  86. package/src/modules/sync_excel/i18n/es.json +107 -0
  87. package/src/modules/sync_excel/i18n/pl.json +107 -0
  88. package/src/modules/sync_excel/index.ts +7 -0
  89. package/src/modules/sync_excel/integration.ts +31 -0
  90. package/src/modules/sync_excel/lib/adapters/customers.ts +1097 -0
  91. package/src/modules/sync_excel/lib/column-detector.ts +91 -0
  92. package/src/modules/sync_excel/lib/mapping.ts +19 -0
  93. package/src/modules/sync_excel/lib/parser.ts +165 -0
  94. package/src/modules/sync_excel/lib/scope.ts +63 -0
  95. package/src/modules/sync_excel/lib/upload-storage.ts +79 -0
  96. package/src/modules/sync_excel/migrations/.snapshot-open-mercato.json +199 -0
  97. package/src/modules/sync_excel/migrations/Migration20260329181120_sync_excel.ts +10 -0
  98. package/src/modules/sync_excel/setup.ts +20 -0
  99. package/src/modules/sync_excel/widgets/injection/upload-config/target-options.ts +351 -0
  100. package/src/modules/sync_excel/widgets/injection/upload-config/widget.client.tsx +1224 -0
  101. package/src/modules/sync_excel/widgets/injection/upload-config/widget.ts +30 -0
  102. package/src/modules/sync_excel/widgets/injection-table.ts +15 -0
@@ -0,0 +1,1224 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation'
5
+ import type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'
6
+ import type { FieldMapping } from '../../../../data_sync/lib/adapter'
7
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
8
+ import { cn } from '@open-mercato/shared/lib/utils'
9
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
10
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
11
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
12
+ import { useCustomFieldDefs } from '@open-mercato/ui/backend/utils/customFieldDefs'
13
+ import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
14
+ import { Badge } from '@open-mercato/ui/primitives/badge'
15
+ import { Button } from '@open-mercato/ui/primitives/button'
16
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@open-mercato/ui/primitives/card'
17
+ import { Input } from '@open-mercato/ui/primitives/input'
18
+ import { Label } from '@open-mercato/ui/primitives/label'
19
+ import { Progress } from '@open-mercato/ui/primitives/progress'
20
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
21
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@open-mercato/ui/primitives/table'
22
+ import { AlertTriangle, CheckCircle2, Play, RefreshCw, Upload, XCircle } from 'lucide-react'
23
+ import {
24
+ buildPeopleSuggestedMapping,
25
+ buildPeopleTargetOptions,
26
+ buildSuggestedMappingSignature,
27
+ findMappingTargetOption,
28
+ SYNC_EXCEL_PEOPLE_CUSTOM_FIELD_ENTITY_IDS,
29
+ type MappingTargetOption,
30
+ type SuggestedMapping,
31
+ } from './target-options'
32
+
33
+ type SyncExcelIntegrationContext = {
34
+ formId?: string
35
+ integrationDetailWidgetSpotId?: string
36
+ integrationId?: string
37
+ activeTab?: string
38
+ state?: {
39
+ isEnabled?: boolean
40
+ } | null
41
+ refreshDetail?: () => Promise<void>
42
+ refreshLogs?: () => Promise<void>
43
+ refreshHealthSnapshot?: () => Promise<void>
44
+ }
45
+
46
+ type SyncExcelIntegrationData = {
47
+ state?: {
48
+ isEnabled?: boolean
49
+ } | null
50
+ }
51
+
52
+ type PreviewRow = Record<string, string | null>
53
+
54
+ type UploadResponse = {
55
+ uploadId: string
56
+ filename: string
57
+ mimeType: string
58
+ fileSize: number
59
+ entityType: 'customers.person'
60
+ headers: string[]
61
+ sampleRows: PreviewRow[]
62
+ totalRows: number
63
+ suggestedMapping: SuggestedMapping
64
+ }
65
+
66
+ type ImportResponse = {
67
+ runId: string
68
+ progressJobId: string | null
69
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
70
+ }
71
+
72
+ type SyncRunDetail = {
73
+ id: string
74
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'paused'
75
+ createdCount: number
76
+ updatedCount: number
77
+ skippedCount: number
78
+ failedCount: number
79
+ lastError: string | null
80
+ progressJobId: string | null
81
+ progressJob: {
82
+ id: string
83
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
84
+ progressPercent: number
85
+ processedCount: number
86
+ totalCount: number | null
87
+ etaSeconds: number | null
88
+ } | null
89
+ }
90
+
91
+ type MappingRowState = {
92
+ sourceColumn: string
93
+ targetField: string
94
+ }
95
+
96
+ type MappingDiagnostics = {
97
+ duplicateTargets: string[]
98
+ hasNameTarget: boolean
99
+ hasIdentityTarget: boolean
100
+ mappedCount: number
101
+ unmappedCount: number
102
+ }
103
+
104
+ type PersistedSessionSnapshot = {
105
+ uploadId: string
106
+ filename: string
107
+ preview: UploadResponse | null
108
+ mappingRows: MappingRowState[]
109
+ matchStrategy: SuggestedMapping['matchStrategy']
110
+ runId: string | null
111
+ progressJobId: string | null
112
+ }
113
+
114
+ const ENTITY_TYPE = 'customers.person' as const
115
+ const SESSION_STORAGE_PREFIX = 'om:sync_excel:session'
116
+ const RUN_POLL_INTERVAL_MS = 4_000
117
+
118
+ const SELECT_CLASS_NAME = 'flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
119
+
120
+ function buildMappingRows(headers: string[], suggestedMapping: SuggestedMapping): MappingRowState[] {
121
+ const mappedFields = new Map(suggestedMapping.fields.map((field) => [field.externalField, field.localField]))
122
+ return headers.map((header) => ({
123
+ sourceColumn: header,
124
+ targetField: mappedFields.get(header) ?? '',
125
+ }))
126
+ }
127
+
128
+ function buildFieldMapping(sourceColumn: string, targetField: string, targetOptions: MappingTargetOption[]): FieldMapping {
129
+ const matchedOption = findMappingTargetOption(targetOptions, targetField)
130
+ const mappingKind = matchedOption?.mappingKind ?? (targetField.startsWith('cf:') ? 'custom_field' : 'core')
131
+ const dedupeRole = matchedOption?.dedupeRole
132
+
133
+ return {
134
+ externalField: sourceColumn,
135
+ localField: targetField,
136
+ mappingKind,
137
+ ...(dedupeRole ? { dedupeRole } : {}),
138
+ }
139
+ }
140
+
141
+ function inferMatchStrategy(rows: MappingRowState[]): SuggestedMapping['matchStrategy'] {
142
+ if (rows.some((row) => row.targetField === 'person.externalId')) return 'externalId'
143
+ if (rows.some((row) => row.targetField === 'person.primaryEmail')) return 'email'
144
+ return 'custom'
145
+ }
146
+
147
+ function buildMapping(
148
+ rows: MappingRowState[],
149
+ matchStrategy: SuggestedMapping['matchStrategy'],
150
+ targetOptions: MappingTargetOption[],
151
+ ): SuggestedMapping {
152
+ const mappedRows = rows.filter((row) => row.targetField.trim().length > 0)
153
+ const fields = mappedRows.map((row) => buildFieldMapping(row.sourceColumn, row.targetField, targetOptions))
154
+ const resolvedMatchStrategy = matchStrategy === 'custom' ? inferMatchStrategy(rows) : matchStrategy
155
+ const matchField = resolvedMatchStrategy === 'externalId'
156
+ ? 'person.externalId'
157
+ : resolvedMatchStrategy === 'email'
158
+ ? 'person.primaryEmail'
159
+ : undefined
160
+
161
+ return {
162
+ entityType: ENTITY_TYPE,
163
+ matchStrategy: resolvedMatchStrategy,
164
+ ...(matchField ? { matchField } : {}),
165
+ fields,
166
+ unmappedColumns: rows.filter((row) => row.targetField.trim().length === 0).map((row) => row.sourceColumn),
167
+ }
168
+ }
169
+
170
+ function buildDiagnostics(rows: MappingRowState[]): MappingDiagnostics {
171
+ const duplicateTargets = Array.from(
172
+ rows
173
+ .filter((row) => row.targetField.trim().length > 0)
174
+ .reduce((accumulator, row) => {
175
+ const nextCount = (accumulator.get(row.targetField) ?? 0) + 1
176
+ accumulator.set(row.targetField, nextCount)
177
+ return accumulator
178
+ }, new Map<string, number>())
179
+ .entries(),
180
+ )
181
+ .filter(([, count]) => count > 1)
182
+ .map(([target]) => target)
183
+
184
+ const hasNameTarget = rows.some((row) => row.targetField === 'person.lastName' || row.targetField === 'person.displayName')
185
+ const hasIdentityTarget = rows.some((row) => row.targetField === 'person.externalId' || row.targetField === 'person.primaryEmail')
186
+ const mappedCount = rows.filter((row) => row.targetField.trim().length > 0).length
187
+
188
+ return {
189
+ duplicateTargets,
190
+ hasNameTarget,
191
+ hasIdentityTarget,
192
+ mappedCount,
193
+ unmappedCount: rows.length - mappedCount,
194
+ }
195
+ }
196
+
197
+ function formatTargetLabel(
198
+ targetField: string,
199
+ t: ReturnType<typeof useT>,
200
+ targetOptions: MappingTargetOption[],
201
+ ): string {
202
+ const option = findMappingTargetOption(targetOptions, targetField)
203
+ if (!option) return targetField
204
+ return option.labelKey ? t(option.labelKey, option.fallback) : option.fallback
205
+ }
206
+
207
+ function getMatchStrategyLabel(
208
+ matchStrategy: SuggestedMapping['matchStrategy'],
209
+ t: ReturnType<typeof useT>,
210
+ ): string {
211
+ if (matchStrategy === 'externalId') {
212
+ return t('sync_excel.widget.matchStrategy.externalId', 'Source record ID (Recommended)')
213
+ }
214
+ if (matchStrategy === 'email') {
215
+ return t('sync_excel.widget.matchStrategy.email', 'Email address')
216
+ }
217
+ return t('sync_excel.widget.matchStrategy.custom', 'Custom matching')
218
+ }
219
+
220
+ function formatFileSize(bytes: number): string {
221
+ if (bytes < 1024) return `${bytes} B`
222
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
223
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
224
+ }
225
+
226
+ function readApiErrorMessage(result: Record<string, unknown> | null | undefined, fallback: string): string {
227
+ const value = result?.error
228
+ if (typeof value === 'string' && value.trim().length > 0) return value
229
+ return fallback
230
+ }
231
+
232
+ function normalizeStatus(status: SyncRunDetail['status'] | ImportResponse['status'] | null | undefined): 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'idle' {
233
+ if (status === 'pending' || status === 'running' || status === 'completed' || status === 'failed' || status === 'cancelled') {
234
+ return status
235
+ }
236
+ return 'idle'
237
+ }
238
+
239
+ function getSessionStorageKey(integrationId: string | undefined): string {
240
+ return `${SESSION_STORAGE_PREFIX}:${integrationId ?? 'sync_excel'}`
241
+ }
242
+
243
+ function readPersistedSessionSnapshot(integrationId: string | undefined): PersistedSessionSnapshot | null {
244
+ if (typeof window === 'undefined') return null
245
+ try {
246
+ const raw = window.sessionStorage.getItem(getSessionStorageKey(integrationId))
247
+ if (!raw) return null
248
+ const parsed = JSON.parse(raw) as Partial<PersistedSessionSnapshot> | null
249
+ if (!parsed || typeof parsed !== 'object') return null
250
+ if (typeof parsed.uploadId !== 'string' || parsed.uploadId.trim().length === 0) return null
251
+ if (typeof parsed.filename !== 'string') return null
252
+ if (!Array.isArray(parsed.mappingRows)) return null
253
+ if (parsed.matchStrategy !== 'externalId' && parsed.matchStrategy !== 'email' && parsed.matchStrategy !== 'custom') {
254
+ return null
255
+ }
256
+ const preview = (
257
+ parsed.preview
258
+ && typeof parsed.preview === 'object'
259
+ && typeof parsed.preview.uploadId === 'string'
260
+ && parsed.preview.uploadId.trim().length > 0
261
+ && typeof parsed.preview.filename === 'string'
262
+ && Array.isArray(parsed.preview.headers)
263
+ && Array.isArray(parsed.preview.sampleRows)
264
+ && typeof parsed.preview.totalRows === 'number'
265
+ && typeof parsed.preview.fileSize === 'number'
266
+ && parsed.preview.entityType === ENTITY_TYPE
267
+ && parsed.preview.suggestedMapping
268
+ )
269
+ ? parsed.preview as UploadResponse
270
+ : null
271
+
272
+ return {
273
+ uploadId: parsed.uploadId,
274
+ filename: parsed.filename,
275
+ preview,
276
+ mappingRows: parsed.mappingRows
277
+ .filter((row): row is MappingRowState => (
278
+ Boolean(row)
279
+ && typeof row.sourceColumn === 'string'
280
+ && typeof row.targetField === 'string'
281
+ )),
282
+ matchStrategy: parsed.matchStrategy,
283
+ runId: typeof parsed.runId === 'string' && parsed.runId.trim().length > 0 ? parsed.runId : null,
284
+ progressJobId: typeof parsed.progressJobId === 'string' && parsed.progressJobId.trim().length > 0 ? parsed.progressJobId : null,
285
+ }
286
+ } catch {
287
+ return null
288
+ }
289
+ }
290
+
291
+ function writePersistedSessionSnapshot(
292
+ integrationId: string | undefined,
293
+ snapshot: PersistedSessionSnapshot | null,
294
+ ): void {
295
+ if (typeof window === 'undefined') return
296
+ try {
297
+ const key = getSessionStorageKey(integrationId)
298
+ if (!snapshot) {
299
+ window.sessionStorage.removeItem(key)
300
+ return
301
+ }
302
+ window.sessionStorage.setItem(key, JSON.stringify(snapshot))
303
+ } catch {
304
+ // sessionStorage can be unavailable in private/locked-down browser contexts; persistence is best-effort.
305
+ }
306
+ }
307
+
308
+ export default function SyncExcelUploadConfigWidget({
309
+ context,
310
+ data,
311
+ }: InjectionWidgetComponentProps<SyncExcelIntegrationContext, SyncExcelIntegrationData>) {
312
+ const pathname = usePathname()
313
+ const router = useRouter()
314
+ const searchParams = useSearchParams()
315
+ const t = useT()
316
+ const { data: customFieldDefs = [] } = useCustomFieldDefs([...SYNC_EXCEL_PEOPLE_CUSTOM_FIELD_ENTITY_IDS])
317
+ const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
318
+ const [preview, setPreview] = React.useState<UploadResponse | null>(null)
319
+ const [mappingRows, setMappingRows] = React.useState<MappingRowState[]>([])
320
+ const [matchStrategy, setMatchStrategy] = React.useState<SuggestedMapping['matchStrategy']>('custom')
321
+ const [isMappingDirty, setIsMappingDirty] = React.useState(false)
322
+ const [runDetail, setRunDetail] = React.useState<SyncRunDetail | null>(null)
323
+ const [runId, setRunId] = React.useState<string | null>(null)
324
+ const [progressJobId, setProgressJobId] = React.useState<string | null>(null)
325
+ const [isUploading, setIsUploading] = React.useState(false)
326
+ const [isRefreshingPreview, setIsRefreshingPreview] = React.useState(false)
327
+ const [isStartingImport, setIsStartingImport] = React.useState(false)
328
+ const [isCancelling, setIsCancelling] = React.useState(false)
329
+ const [isRestoringSession, setIsRestoringSession] = React.useState(true)
330
+ const mutationContextId = `${context.formId ?? 'sync-excel'}:sync-excel-import`
331
+ const lastAppliedSuggestionSignatureRef = React.useRef<string | null>(null)
332
+ const restoringSnapshotRef = React.useRef<PersistedSessionSnapshot | null>(null)
333
+ const lastRestoreRequestKeyRef = React.useRef<string | null>(null)
334
+ const { runMutation } = useGuardedMutation<Record<string, unknown>>({
335
+ contextId: mutationContextId,
336
+ spotId: context.integrationDetailWidgetSpotId,
337
+ })
338
+
339
+ const targetOptions = React.useMemo(
340
+ () => buildPeopleTargetOptions(customFieldDefs),
341
+ [customFieldDefs],
342
+ )
343
+ const previewSuggestion = React.useMemo(
344
+ () => preview ? buildPeopleSuggestedMapping(preview.headers, preview.suggestedMapping, customFieldDefs) : null,
345
+ [customFieldDefs, preview],
346
+ )
347
+ const resolvedIntegrationEnabled = data?.state?.isEnabled ?? context.state?.isEnabled ?? true
348
+ const diagnostics = React.useMemo(() => buildDiagnostics(mappingRows), [mappingRows])
349
+ const canStartImport = Boolean(preview)
350
+ && diagnostics.duplicateTargets.length === 0
351
+ && diagnostics.hasNameTarget
352
+ && diagnostics.hasIdentityTarget
353
+ && mappingRows.some((row) => row.targetField.trim().length > 0)
354
+ const importStatus = normalizeStatus(runDetail?.status ?? (runId ? 'pending' : null))
355
+ const progressValue = runDetail?.progressJob?.progressPercent ?? 0
356
+ const isRunActive = importStatus === 'pending' || importStatus === 'running'
357
+ const searchParamsKey = searchParams?.toString() ?? ''
358
+ const uploadIdFromUrl = searchParams?.get('uploadId') ?? null
359
+ const runIdFromUrl = searchParams?.get('runId') ?? null
360
+ const shouldHideInteractiveContent = isRestoringSession && Boolean(uploadIdFromUrl)
361
+ const hasExistingRunForCurrentUpload = Boolean(preview?.uploadId && runId)
362
+ const hasSafeDedupeStrategy = matchStrategy === 'externalId' || matchStrategy === 'email'
363
+
364
+ const replaceQueryParams = React.useCallback((updates: Record<string, string | null | undefined>) => {
365
+ const params = new URLSearchParams(searchParamsKey)
366
+ for (const [key, value] of Object.entries(updates)) {
367
+ if (typeof value === 'string' && value.trim().length > 0) {
368
+ params.set(key, value)
369
+ } else {
370
+ params.delete(key)
371
+ }
372
+ }
373
+ const query = params.toString()
374
+ if (query === searchParamsKey) return
375
+ router.replace(query ? `${pathname}?${query}` : pathname)
376
+ }, [pathname, router, searchParamsKey])
377
+
378
+ const clearPersistedSession = React.useCallback(() => {
379
+ writePersistedSessionSnapshot(context.integrationId, null)
380
+ replaceQueryParams({ uploadId: null, runId: null })
381
+ }, [context.integrationId, replaceQueryParams])
382
+
383
+ const persistCurrentSession = React.useCallback((next?: Partial<PersistedSessionSnapshot>) => {
384
+ const persistedPreview = next?.preview ?? preview ?? null
385
+ const uploadId = next?.uploadId ?? persistedPreview?.uploadId ?? null
386
+ const filename = next?.filename ?? persistedPreview?.filename ?? null
387
+ if (!uploadId || !filename) {
388
+ writePersistedSessionSnapshot(context.integrationId, null)
389
+ return
390
+ }
391
+
392
+ writePersistedSessionSnapshot(context.integrationId, {
393
+ uploadId,
394
+ filename,
395
+ preview: persistedPreview,
396
+ mappingRows: next?.mappingRows ?? mappingRows,
397
+ matchStrategy: next?.matchStrategy ?? matchStrategy,
398
+ runId: next?.runId ?? runId,
399
+ progressJobId: next?.progressJobId ?? progressJobId,
400
+ })
401
+ }, [context.integrationId, mappingRows, matchStrategy, preview, progressJobId, runId])
402
+
403
+ const syncPreviewState = React.useCallback((nextPreview: UploadResponse, options?: {
404
+ preserveManualState?: boolean
405
+ restoredSnapshot?: PersistedSessionSnapshot | null
406
+ }) => {
407
+ const nextSuggestedMapping = buildPeopleSuggestedMapping(nextPreview.headers, nextPreview.suggestedMapping, customFieldDefs)
408
+ setPreview(nextPreview)
409
+
410
+ const restoredSnapshot = options?.restoredSnapshot
411
+ const shouldRestoreSnapshot = Boolean(
412
+ restoredSnapshot
413
+ && restoredSnapshot.uploadId === nextPreview.uploadId
414
+ && restoredSnapshot.mappingRows.length > 0,
415
+ )
416
+
417
+ if (shouldRestoreSnapshot) {
418
+ setMappingRows(restoredSnapshot!.mappingRows)
419
+ setMatchStrategy(restoredSnapshot!.matchStrategy)
420
+ setRunId(restoredSnapshot!.runId)
421
+ setProgressJobId(restoredSnapshot!.progressJobId)
422
+ setIsMappingDirty(true)
423
+ lastAppliedSuggestionSignatureRef.current = null
424
+ persistCurrentSession({
425
+ uploadId: nextPreview.uploadId,
426
+ filename: nextPreview.filename,
427
+ preview: nextPreview,
428
+ mappingRows: restoredSnapshot!.mappingRows,
429
+ matchStrategy: restoredSnapshot!.matchStrategy,
430
+ runId: restoredSnapshot!.runId,
431
+ progressJobId: restoredSnapshot!.progressJobId,
432
+ })
433
+ replaceQueryParams({
434
+ uploadId: nextPreview.uploadId,
435
+ runId: restoredSnapshot!.runId,
436
+ })
437
+ return
438
+ }
439
+
440
+ if (options?.preserveManualState && preview?.uploadId === nextPreview.uploadId) {
441
+ persistCurrentSession({
442
+ uploadId: nextPreview.uploadId,
443
+ filename: nextPreview.filename,
444
+ preview: nextPreview,
445
+ })
446
+ replaceQueryParams({ uploadId: nextPreview.uploadId })
447
+ return
448
+ }
449
+
450
+ const nextRows = buildMappingRows(nextPreview.headers, nextSuggestedMapping)
451
+ setMappingRows(nextRows)
452
+ setMatchStrategy(nextSuggestedMapping.matchStrategy)
453
+ setIsMappingDirty(false)
454
+ lastAppliedSuggestionSignatureRef.current = buildSuggestedMappingSignature(nextPreview.headers, nextSuggestedMapping)
455
+ persistCurrentSession({
456
+ uploadId: nextPreview.uploadId,
457
+ filename: nextPreview.filename,
458
+ preview: nextPreview,
459
+ mappingRows: nextRows,
460
+ matchStrategy: nextSuggestedMapping.matchStrategy,
461
+ runId: null,
462
+ progressJobId: null,
463
+ })
464
+ replaceQueryParams({
465
+ uploadId: nextPreview.uploadId,
466
+ runId: null,
467
+ })
468
+ }, [customFieldDefs, persistCurrentSession, preview?.uploadId, replaceQueryParams])
469
+
470
+ React.useEffect(() => {
471
+ if (!preview || !previewSuggestion) return
472
+ if (isMappingDirty) return
473
+ const nextSignature = buildSuggestedMappingSignature(preview.headers, previewSuggestion)
474
+ if (lastAppliedSuggestionSignatureRef.current === nextSignature) return
475
+ setMappingRows(buildMappingRows(preview.headers, previewSuggestion))
476
+ setMatchStrategy(previewSuggestion.matchStrategy)
477
+ lastAppliedSuggestionSignatureRef.current = nextSignature
478
+ }, [isMappingDirty, preview, previewSuggestion])
479
+
480
+ const refreshRunDetail = React.useCallback(async (currentRunId: string) => {
481
+ const call = await apiCall<SyncRunDetail>(`/api/data_sync/runs/${encodeURIComponent(currentRunId)}`, undefined, { fallback: null })
482
+ if (call.ok && call.result) {
483
+ setRunDetail(call.result)
484
+ setProgressJobId(call.result.progressJobId ?? null)
485
+ persistCurrentSession({
486
+ runId: currentRunId,
487
+ progressJobId: call.result.progressJobId ?? null,
488
+ })
489
+ return call.result
490
+ }
491
+ return null
492
+ }, [persistCurrentSession])
493
+
494
+ React.useEffect(() => {
495
+ if (!preview?.uploadId) return
496
+ persistCurrentSession()
497
+ }, [mappingRows, matchStrategy, persistCurrentSession, preview?.uploadId, progressJobId, runId])
498
+
499
+ React.useEffect(() => {
500
+ const persistedSnapshot = readPersistedSessionSnapshot(context.integrationId)
501
+ const resolvedUploadId = uploadIdFromUrl ?? persistedSnapshot?.uploadId ?? null
502
+ const resolvedRunId = runIdFromUrl ?? persistedSnapshot?.runId ?? null
503
+
504
+ if (!resolvedUploadId) {
505
+ restoringSnapshotRef.current = null
506
+ lastRestoreRequestKeyRef.current = null
507
+ setIsRestoringSession(false)
508
+ return
509
+ }
510
+
511
+ if (!uploadIdFromUrl && persistedSnapshot?.uploadId === resolvedUploadId) {
512
+ replaceQueryParams({
513
+ uploadId: resolvedUploadId,
514
+ runId: resolvedRunId,
515
+ })
516
+ }
517
+
518
+ if (preview?.uploadId === resolvedUploadId && mappingRows.length > 0) {
519
+ if (resolvedRunId && runId !== resolvedRunId) {
520
+ setRunId(resolvedRunId)
521
+ }
522
+ setIsRestoringSession(false)
523
+ return
524
+ }
525
+
526
+ const restoreRequestKey = `${resolvedUploadId}:${resolvedRunId ?? ''}`
527
+ if (lastRestoreRequestKeyRef.current === restoreRequestKey) {
528
+ setIsRestoringSession(false)
529
+ return
530
+ }
531
+
532
+ lastRestoreRequestKeyRef.current = restoreRequestKey
533
+
534
+ let cancelled = false
535
+ restoringSnapshotRef.current = persistedSnapshot
536
+ const persistedPreview = restoringSnapshotRef.current?.preview?.uploadId === resolvedUploadId
537
+ ? restoringSnapshotRef.current.preview
538
+ : null
539
+
540
+ if (persistedPreview) {
541
+ syncPreviewState(persistedPreview, {
542
+ restoredSnapshot: restoringSnapshotRef.current,
543
+ })
544
+ setIsRestoringSession(false)
545
+ } else {
546
+ setIsRestoringSession(true)
547
+ }
548
+
549
+ const restoreSession = async () => {
550
+ const call = await apiCall<UploadResponse>(
551
+ `/api/sync_excel/preview?uploadId=${encodeURIComponent(resolvedUploadId)}&entityType=${encodeURIComponent(ENTITY_TYPE)}`,
552
+ undefined,
553
+ { fallback: null },
554
+ )
555
+
556
+ if (cancelled) return
557
+
558
+ if (!call.ok || !call.result) {
559
+ setPreview(null)
560
+ setMappingRows([])
561
+ setMatchStrategy('custom')
562
+ setRunId(null)
563
+ setRunDetail(null)
564
+ setProgressJobId(null)
565
+ clearPersistedSession()
566
+ lastRestoreRequestKeyRef.current = null
567
+ setIsRestoringSession(false)
568
+ return
569
+ }
570
+
571
+ syncPreviewState(call.result, {
572
+ restoredSnapshot: restoringSnapshotRef.current,
573
+ })
574
+
575
+ if (resolvedRunId && !(restoringSnapshotRef.current?.runId)) {
576
+ setRunId(resolvedRunId)
577
+ }
578
+
579
+ setIsRestoringSession(false)
580
+ }
581
+
582
+ void restoreSession()
583
+
584
+ return () => {
585
+ cancelled = true
586
+ }
587
+ }, [clearPersistedSession, context.integrationId, mappingRows.length, preview?.uploadId, replaceQueryParams, runId, runIdFromUrl, syncPreviewState, uploadIdFromUrl])
588
+
589
+ React.useEffect(() => {
590
+ if (!runId) return
591
+
592
+ let cancelled = false
593
+ let timeoutId: ReturnType<typeof setTimeout> | null = null
594
+
595
+ const poll = async () => {
596
+ const detail = await refreshRunDetail(runId)
597
+ if (cancelled) return
598
+ if (context.activeTab === 'logs') {
599
+ await context.refreshLogs?.()
600
+ }
601
+ if (context.activeTab === 'health' || normalizeStatus(detail?.status) !== 'idle') {
602
+ await context.refreshHealthSnapshot?.()
603
+ }
604
+ const status = normalizeStatus(detail?.status)
605
+ if (status === 'pending' || status === 'running') {
606
+ timeoutId = setTimeout(() => {
607
+ void poll()
608
+ }, RUN_POLL_INTERVAL_MS)
609
+ }
610
+ }
611
+
612
+ void poll()
613
+
614
+ return () => {
615
+ cancelled = true
616
+ if (timeoutId) clearTimeout(timeoutId)
617
+ }
618
+ }, [context.activeTab, context.refreshHealthSnapshot, context.refreshLogs, refreshRunDetail, runId])
619
+
620
+ const handleUpload = React.useCallback(async () => {
621
+ if (!selectedFile) {
622
+ flash(t('sync_excel.widget.messages.fileRequired', 'Select a CSV file first.'), 'error')
623
+ return
624
+ }
625
+
626
+ setIsUploading(true)
627
+ try {
628
+ const formData = new FormData()
629
+ formData.set('file', selectedFile)
630
+ formData.set('entityType', ENTITY_TYPE)
631
+
632
+ const call = await runMutation({
633
+ operation: () => apiCall<UploadResponse>('/api/sync_excel/upload', {
634
+ method: 'POST',
635
+ body: formData,
636
+ }, { fallback: null }),
637
+ mutationPayload: {
638
+ integrationId: context.integrationId ?? 'sync_excel',
639
+ entityType: ENTITY_TYPE,
640
+ fileName: selectedFile.name,
641
+ },
642
+ context: {
643
+ ...context,
644
+ operation: 'create',
645
+ actionId: 'sync-excel-upload',
646
+ },
647
+ })
648
+
649
+ if (!call.ok || !call.result) {
650
+ flash(
651
+ readApiErrorMessage(call.result as Record<string, unknown> | null, t('sync_excel.widget.messages.uploadError', 'Failed to upload CSV file.')),
652
+ 'error',
653
+ )
654
+ return
655
+ }
656
+
657
+ syncPreviewState(call.result)
658
+ setSelectedFile(null)
659
+ setRunId(null)
660
+ setRunDetail(null)
661
+ setProgressJobId(null)
662
+ flash(t('sync_excel.widget.messages.uploadSuccess', 'CSV preview is ready.'), 'success')
663
+ } catch {
664
+ flash(t('sync_excel.widget.messages.uploadError', 'Failed to upload CSV file.'), 'error')
665
+ } finally {
666
+ setIsUploading(false)
667
+ }
668
+ }, [context, runMutation, selectedFile, syncPreviewState, t])
669
+
670
+ const handleRefreshPreview = React.useCallback(async () => {
671
+ if (!preview) return
672
+ setIsRefreshingPreview(true)
673
+ try {
674
+ const call = await apiCall<UploadResponse>(
675
+ `/api/sync_excel/preview?uploadId=${encodeURIComponent(preview.uploadId)}&entityType=${encodeURIComponent(ENTITY_TYPE)}`,
676
+ undefined,
677
+ { fallback: null },
678
+ )
679
+ if (!call.ok || !call.result) {
680
+ flash(t('sync_excel.widget.messages.previewError', 'Failed to refresh preview.'), 'error')
681
+ return
682
+ }
683
+ syncPreviewState(call.result, { preserveManualState: true })
684
+ flash(t('sync_excel.widget.messages.previewRefreshed', 'Preview refreshed.'), 'success')
685
+ } catch {
686
+ flash(t('sync_excel.widget.messages.previewError', 'Failed to refresh preview.'), 'error')
687
+ } finally {
688
+ setIsRefreshingPreview(false)
689
+ }
690
+ }, [preview, syncPreviewState, t])
691
+
692
+ const handleTargetFieldChange = React.useCallback((sourceColumn: string, targetField: string) => {
693
+ setIsMappingDirty(true)
694
+ setMappingRows((current) => current.map((row) => row.sourceColumn === sourceColumn ? { ...row, targetField } : row))
695
+ }, [])
696
+
697
+ const handleResetToSuggested = React.useCallback(() => {
698
+ if (!preview || !previewSuggestion) return
699
+ setMappingRows(buildMappingRows(preview.headers, previewSuggestion))
700
+ setMatchStrategy(previewSuggestion.matchStrategy)
701
+ setIsMappingDirty(false)
702
+ lastAppliedSuggestionSignatureRef.current = buildSuggestedMappingSignature(preview.headers, previewSuggestion)
703
+ flash(t('sync_excel.widget.messages.mappingReset', 'Suggested mapping restored.'), 'success')
704
+ }, [preview, previewSuggestion, t])
705
+
706
+ const handleStartImport = React.useCallback(async () => {
707
+ if (!preview) return
708
+
709
+ const mapping = buildMapping(mappingRows, matchStrategy, targetOptions)
710
+ setIsStartingImport(true)
711
+
712
+ try {
713
+ const call = await runMutation({
714
+ operation: () => apiCall<ImportResponse>('/api/sync_excel/import', {
715
+ method: 'POST',
716
+ headers: { 'Content-Type': 'application/json' },
717
+ body: JSON.stringify({
718
+ uploadId: preview.uploadId,
719
+ entityType: ENTITY_TYPE,
720
+ mapping,
721
+ }),
722
+ }, { fallback: null }),
723
+ mutationPayload: {
724
+ integrationId: context.integrationId ?? 'sync_excel',
725
+ uploadId: preview.uploadId,
726
+ entityType: ENTITY_TYPE,
727
+ mapping,
728
+ },
729
+ context: {
730
+ ...context,
731
+ operation: 'create',
732
+ actionId: 'sync-excel-start-import',
733
+ },
734
+ })
735
+
736
+ if (!call.ok || !call.result) {
737
+ flash(
738
+ readApiErrorMessage(call.result as Record<string, unknown> | null, t('sync_excel.widget.messages.importError', 'Failed to start import run.')),
739
+ 'error',
740
+ )
741
+ return
742
+ }
743
+
744
+ setRunId(call.result.runId)
745
+ setProgressJobId(call.result.progressJobId)
746
+ replaceQueryParams({
747
+ uploadId: preview.uploadId,
748
+ runId: call.result.runId,
749
+ })
750
+ persistCurrentSession({
751
+ uploadId: preview.uploadId,
752
+ filename: preview.filename,
753
+ runId: call.result.runId,
754
+ progressJobId: call.result.progressJobId,
755
+ })
756
+ flash(t('sync_excel.widget.messages.importStarted', 'Import run started.'), 'success')
757
+ await refreshRunDetail(call.result.runId)
758
+ await context.refreshLogs?.()
759
+ await context.refreshHealthSnapshot?.()
760
+ } catch {
761
+ flash(t('sync_excel.widget.messages.importError', 'Failed to start import run.'), 'error')
762
+ } finally {
763
+ setIsStartingImport(false)
764
+ }
765
+ }, [context, mappingRows, matchStrategy, persistCurrentSession, preview, refreshRunDetail, replaceQueryParams, runMutation, t, targetOptions])
766
+
767
+ const handleCancelRun = React.useCallback(async () => {
768
+ if (!runId) return
769
+
770
+ setIsCancelling(true)
771
+ try {
772
+ const call = await runMutation({
773
+ operation: () => apiCall<{ ok?: boolean }>(`/api/data_sync/runs/${encodeURIComponent(runId)}/cancel`, {
774
+ method: 'POST',
775
+ }, { fallback: null }),
776
+ mutationPayload: {
777
+ integrationId: context.integrationId ?? 'sync_excel',
778
+ runId,
779
+ },
780
+ context: {
781
+ ...context,
782
+ operation: 'update',
783
+ actionId: 'sync-excel-cancel-import',
784
+ },
785
+ })
786
+
787
+ if (!call.ok) {
788
+ flash(t('sync_excel.widget.messages.cancelError', 'Failed to cancel import run.'), 'error')
789
+ return
790
+ }
791
+
792
+ flash(t('sync_excel.widget.messages.cancelSuccess', 'Import run cancelled.'), 'success')
793
+ await refreshRunDetail(runId)
794
+ await context.refreshLogs?.()
795
+ await context.refreshHealthSnapshot?.()
796
+ } catch {
797
+ flash(t('sync_excel.widget.messages.cancelError', 'Failed to cancel import run.'), 'error')
798
+ } finally {
799
+ setIsCancelling(false)
800
+ }
801
+ }, [context, refreshRunDetail, runId, runMutation, t])
802
+
803
+ const handleRefreshAll = React.useCallback(async () => {
804
+ if (!runId) return
805
+ await refreshRunDetail(runId)
806
+ await context.refreshLogs?.()
807
+ await context.refreshHealthSnapshot?.()
808
+ }, [context, refreshRunDetail, runId])
809
+
810
+ const firstSampleRow = preview?.sampleRows[0] ?? null
811
+
812
+ return (
813
+ <div className="space-y-6">
814
+ {isRestoringSession ? (
815
+ <Card>
816
+ <CardContent className="flex items-center gap-3 py-6">
817
+ <Spinner className="size-4" />
818
+ <div className="text-sm text-muted-foreground">
819
+ {t('sync_excel.widget.messages.restoringSession', 'Restoring the last CSV session...')}
820
+ </div>
821
+ </CardContent>
822
+ </Card>
823
+ ) : null}
824
+
825
+ {!resolvedIntegrationEnabled ? (
826
+ <StatusAlert
827
+ variant="warning"
828
+ title={t('sync_excel.widget.disabled.title', 'Integration disabled')}
829
+ message={t('sync_excel.widget.disabled.message', 'Enable this integration to upload CSV files and start imports.')}
830
+ />
831
+ ) : null}
832
+
833
+ {!shouldHideInteractiveContent ? (
834
+ <Card>
835
+ <CardHeader>
836
+ <CardTitle>{t('sync_excel.widget.upload.title', 'Upload CSV')}</CardTitle>
837
+ <CardDescription>
838
+ {t('sync_excel.widget.upload.description', 'Upload a CSV file, review the suggested people mapping, and start a background import run.')}
839
+ </CardDescription>
840
+ </CardHeader>
841
+ <CardContent className="space-y-4">
842
+ <div className="space-y-2">
843
+ <Label htmlFor="sync-excel-upload-file">{t('sync_excel.widget.upload.fileLabel', 'CSV file')}</Label>
844
+ <Input
845
+ id="sync-excel-upload-file"
846
+ type="file"
847
+ accept=".csv,text/csv"
848
+ onChange={(event) => setSelectedFile(event.target.files?.[0] ?? null)}
849
+ disabled={isUploading || isRunActive || isRestoringSession}
850
+ />
851
+ </div>
852
+
853
+ <div className="flex flex-wrap items-center gap-3">
854
+ <Button type="button" onClick={() => void handleUpload()} disabled={!selectedFile || isUploading || isRunActive || isRestoringSession}>
855
+ {isUploading ? <Spinner className="mr-2 size-4" /> : <Upload className="mr-2 size-4" />}
856
+ {isUploading
857
+ ? t('sync_excel.widget.actions.uploadPending', 'Uploading...')
858
+ : t('sync_excel.widget.actions.upload', 'Upload and preview')}
859
+ </Button>
860
+ {preview ? (
861
+ <Button type="button" variant="outline" onClick={() => void handleRefreshPreview()} disabled={isRefreshingPreview || isUploading || isRunActive || isRestoringSession}>
862
+ {isRefreshingPreview ? <Spinner className="mr-2 size-4" /> : <RefreshCw className="mr-2 size-4" />}
863
+ {isRefreshingPreview
864
+ ? t('sync_excel.widget.actions.refreshPending', 'Refreshing...')
865
+ : t('sync_excel.widget.actions.refresh', 'Refresh preview')}
866
+ </Button>
867
+ ) : null}
868
+ </div>
869
+
870
+ {selectedFile ? (
871
+ <div className="text-sm text-muted-foreground">
872
+ {t('sync_excel.widget.upload.selectedFile', 'Selected file')}: <span className="font-medium text-foreground">{selectedFile.name}</span>
873
+ </div>
874
+ ) : null}
875
+ </CardContent>
876
+ </Card>
877
+ ) : null}
878
+
879
+ {preview && !shouldHideInteractiveContent ? (
880
+ <Card>
881
+ <CardHeader>
882
+ <div className="flex flex-wrap items-start justify-between gap-3">
883
+ <div className="space-y-1">
884
+ <CardTitle>{t('sync_excel.widget.preview.title', 'Preview and mapping')}</CardTitle>
885
+ <CardDescription>
886
+ {t('sync_excel.widget.preview.description', 'Every source column stays visible here. Leave a target empty to keep the column unmapped for now.')}
887
+ </CardDescription>
888
+ </div>
889
+ <div className="flex flex-wrap gap-2">
890
+ <Badge variant="outline">{preview.filename}</Badge>
891
+ <Badge variant="outline">{t('sync_excel.widget.preview.rows', '{count} rows', { count: preview.totalRows })}</Badge>
892
+ <Badge variant="outline">{formatFileSize(preview.fileSize)}</Badge>
893
+ </div>
894
+ </div>
895
+ </CardHeader>
896
+ <CardContent className="space-y-4">
897
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
898
+ <MetricCard
899
+ label={t('sync_excel.widget.metrics.mapped', 'Mapped columns')}
900
+ value={String(diagnostics.mappedCount)}
901
+ />
902
+ <MetricCard
903
+ label={t('sync_excel.widget.metrics.unmapped', 'Unmapped columns')}
904
+ value={String(diagnostics.unmappedCount)}
905
+ />
906
+ <MetricCard
907
+ label={t('sync_excel.widget.metrics.identity', 'Identity strategy')}
908
+ value={getMatchStrategyLabel(matchStrategy, t)}
909
+ />
910
+ <MetricCard
911
+ label={t('sync_excel.widget.metrics.entity', 'Target entity')}
912
+ value={ENTITY_TYPE}
913
+ />
914
+ </div>
915
+
916
+ <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
917
+ <div className="overflow-hidden rounded-lg border">
918
+ <Table>
919
+ <TableHeader>
920
+ <TableRow>
921
+ <TableHead>{t('sync_excel.widget.mapping.sourceColumn', 'Source column')}</TableHead>
922
+ <TableHead>{t('sync_excel.widget.mapping.sampleValue', 'Sample value')}</TableHead>
923
+ <TableHead>{t('sync_excel.widget.mapping.targetField', 'Target field')}</TableHead>
924
+ </TableRow>
925
+ </TableHeader>
926
+ <TableBody>
927
+ {mappingRows.map((row) => (
928
+ <TableRow key={row.sourceColumn}>
929
+ <TableCell className="font-medium">{row.sourceColumn}</TableCell>
930
+ <TableCell className="max-w-[260px] truncate text-muted-foreground">
931
+ {firstSampleRow?.[row.sourceColumn] ?? '—'}
932
+ </TableCell>
933
+ <TableCell>
934
+ <select
935
+ className={SELECT_CLASS_NAME}
936
+ value={row.targetField}
937
+ onChange={(event) => handleTargetFieldChange(row.sourceColumn, event.target.value)}
938
+ disabled={isStartingImport || isRunActive}
939
+ >
940
+ <option value="">{t('sync_excel.mapping.targets.ignore', 'Leave unmapped')}</option>
941
+ {targetOptions.map((option) => (
942
+ <option key={option.value} value={option.value}>
943
+ {option.labelKey ? t(option.labelKey, option.fallback) : option.fallback}
944
+ </option>
945
+ ))}
946
+ </select>
947
+ </TableCell>
948
+ </TableRow>
949
+ ))}
950
+ </TableBody>
951
+ </Table>
952
+ </div>
953
+
954
+ <div className="space-y-4">
955
+ <div className="space-y-2">
956
+ <Label htmlFor="sync-excel-match-strategy">
957
+ {t('sync_excel.widget.matchStrategy.label', 'How to match existing people')}
958
+ </Label>
959
+ <p className="text-sm text-muted-foreground">
960
+ {t(
961
+ 'sync_excel.widget.matchStrategy.help',
962
+ 'This decides whether imported rows update existing people or create new ones.',
963
+ )}
964
+ </p>
965
+ <select
966
+ id="sync-excel-match-strategy"
967
+ className={SELECT_CLASS_NAME}
968
+ value={matchStrategy}
969
+ onChange={(event) => {
970
+ setIsMappingDirty(true)
971
+ setMatchStrategy(event.target.value as SuggestedMapping['matchStrategy'])
972
+ }}
973
+ disabled={isStartingImport || isRunActive}
974
+ >
975
+ <option value="externalId">
976
+ {t('sync_excel.widget.matchStrategy.externalId', 'Source record ID (Recommended)')}
977
+ </option>
978
+ <option value="email">{t('sync_excel.widget.matchStrategy.email', 'Email address')}</option>
979
+ <option value="custom">{t('sync_excel.widget.matchStrategy.custom', 'Custom matching')}</option>
980
+ </select>
981
+ <p className="text-sm text-muted-foreground">
982
+ {matchStrategy === 'externalId'
983
+ ? t(
984
+ 'sync_excel.widget.matchStrategy.externalIdDescription',
985
+ 'Best for repeated imports from the same source. Future imports update the same people instead of creating duplicates.',
986
+ )
987
+ : matchStrategy === 'email'
988
+ ? t(
989
+ 'sync_excel.widget.matchStrategy.emailDescription',
990
+ 'Matches people by email. Works best when each person has one reliable, unique email address.',
991
+ )
992
+ : t(
993
+ 'sync_excel.widget.matchStrategy.customDescription',
994
+ 'Advanced option. Use only if you know how existing records should be identified in this import.',
995
+ )}
996
+ </p>
997
+ </div>
998
+
999
+ <div className="flex flex-wrap gap-2">
1000
+ <Button type="button" variant="outline" onClick={handleResetToSuggested} disabled={isStartingImport || isRunActive}>
1001
+ <RefreshCw className="mr-2 size-4" />
1002
+ {t('sync_excel.widget.actions.reset', 'Reset to suggestion')}
1003
+ </Button>
1004
+ <Button type="button" onClick={() => void handleStartImport()} disabled={!canStartImport || isStartingImport || isRunActive}>
1005
+ {isStartingImport ? <Spinner className="mr-2 size-4" /> : <Play className="mr-2 size-4" />}
1006
+ {isStartingImport
1007
+ ? t('sync_excel.widget.actions.startPending', 'Starting...')
1008
+ : t('sync_excel.widget.actions.start', 'Start import')}
1009
+ </Button>
1010
+ </div>
1011
+
1012
+ {matchStrategy === 'custom' ? (
1013
+ <StatusAlert
1014
+ variant="warning"
1015
+ title={t('sync_excel.widget.matchStrategy.customWarningTitle', 'Use custom matching with care')}
1016
+ message={t(
1017
+ 'sync_excel.widget.matchStrategy.customWarningMessage',
1018
+ 'Custom matching may create duplicates if the mapped fields do not uniquely identify a person.',
1019
+ )}
1020
+ />
1021
+ ) : null}
1022
+
1023
+ {hasExistingRunForCurrentUpload ? (
1024
+ <StatusAlert
1025
+ variant="warning"
1026
+ title={t('sync_excel.widget.validation.reimportTitle', 'This upload already has a run')}
1027
+ message={t(
1028
+ 'sync_excel.widget.validation.reimportMessage',
1029
+ 'Starting another import from the same uploaded CSV may create duplicates unless your matching strategy reliably identifies existing people.',
1030
+ )}
1031
+ />
1032
+ ) : null}
1033
+
1034
+ {!hasSafeDedupeStrategy ? (
1035
+ <StatusAlert
1036
+ variant="warning"
1037
+ title={t('sync_excel.widget.validation.duplicateRiskTitle', 'Duplicate risk')}
1038
+ message={t(
1039
+ 'sync_excel.widget.validation.duplicateRiskMessage',
1040
+ 'This mapping does not use the recommended External ID or Email matching strategy. Re-importing the same dataset may create duplicate people.',
1041
+ )}
1042
+ />
1043
+ ) : null}
1044
+
1045
+ {diagnostics.duplicateTargets.length > 0 ? (
1046
+ <StatusAlert
1047
+ variant="error"
1048
+ title={t('sync_excel.widget.validation.duplicateTargetsTitle', 'Duplicate target fields')}
1049
+ message={t(
1050
+ 'sync_excel.widget.validation.duplicateTargetsMessage',
1051
+ 'Each target field can only be mapped once in this foundation slice: {targets}',
1052
+ { targets: diagnostics.duplicateTargets.map((target) => formatTargetLabel(target, t, targetOptions)).join(', ') },
1053
+ )}
1054
+ />
1055
+ ) : null}
1056
+
1057
+ {!diagnostics.hasIdentityTarget ? (
1058
+ <StatusAlert
1059
+ variant="warning"
1060
+ title={t('sync_excel.widget.validation.identityTitle', 'Map an identity field')}
1061
+ message={t('sync_excel.widget.validation.identityMessage', 'Map either External ID or Primary email so reimports can match existing people.')}
1062
+ />
1063
+ ) : null}
1064
+
1065
+ {!diagnostics.hasNameTarget ? (
1066
+ <StatusAlert
1067
+ variant="warning"
1068
+ title={t('sync_excel.widget.validation.nameTitle', 'Map a name field')}
1069
+ message={t('sync_excel.widget.validation.nameMessage', 'Map Last name or Display name so the importer can build person records.')}
1070
+ />
1071
+ ) : null}
1072
+
1073
+ {diagnostics.hasIdentityTarget && diagnostics.hasNameTarget && diagnostics.duplicateTargets.length === 0 ? (
1074
+ <StatusAlert
1075
+ variant="info"
1076
+ title={t('sync_excel.widget.validation.readyTitle', 'Ready to import')}
1077
+ message={t('sync_excel.widget.validation.readyMessage', 'The current mapping is valid for the first customers.person slice.')}
1078
+ />
1079
+ ) : null}
1080
+ </div>
1081
+ </div>
1082
+ </CardContent>
1083
+ </Card>
1084
+ ) : null}
1085
+
1086
+ {runId && !shouldHideInteractiveContent ? (
1087
+ <Card>
1088
+ <CardHeader>
1089
+ <div className="flex flex-wrap items-start justify-between gap-3">
1090
+ <div className="space-y-1">
1091
+ <CardTitle>{t('sync_excel.widget.run.title', 'Import run status')}</CardTitle>
1092
+ <CardDescription>
1093
+ {t('sync_excel.widget.run.description', 'The import runs in the background through Data Sync and updates this view as progress changes.')}
1094
+ </CardDescription>
1095
+ </div>
1096
+ <div className="flex flex-wrap items-center gap-2">
1097
+ <RunStatusBadge status={importStatus} t={t} />
1098
+ {progressJobId ? <Badge variant="outline">{progressJobId}</Badge> : null}
1099
+ </div>
1100
+ </div>
1101
+ </CardHeader>
1102
+ <CardContent className="space-y-4">
1103
+ <div className="space-y-2">
1104
+ <div className="flex items-center justify-between text-sm">
1105
+ <span className="text-muted-foreground">{t('sync_excel.widget.run.progressLabel', 'Progress')}</span>
1106
+ <span className="font-medium">{progressValue}%</span>
1107
+ </div>
1108
+ <Progress value={progressValue} className="h-2.5" />
1109
+ </div>
1110
+
1111
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
1112
+ <MetricCard label={t('sync_excel.widget.run.metrics.created', 'Created')} value={String(runDetail?.createdCount ?? 0)} />
1113
+ <MetricCard label={t('sync_excel.widget.run.metrics.updated', 'Updated')} value={String(runDetail?.updatedCount ?? 0)} />
1114
+ <MetricCard label={t('sync_excel.widget.run.metrics.skipped', 'Skipped')} value={String(runDetail?.skippedCount ?? 0)} />
1115
+ <MetricCard label={t('sync_excel.widget.run.metrics.failed', 'Failed')} value={String(runDetail?.failedCount ?? 0)} />
1116
+ <MetricCard label={t('sync_excel.widget.run.metrics.processed', 'Processed')} value={String(runDetail?.progressJob?.processedCount ?? 0)} />
1117
+ </div>
1118
+
1119
+ {runDetail?.lastError ? (
1120
+ <StatusAlert
1121
+ variant="error"
1122
+ title={t('sync_excel.widget.run.errorTitle', 'Run error')}
1123
+ message={runDetail.lastError}
1124
+ />
1125
+ ) : null}
1126
+
1127
+ <div className="flex flex-wrap gap-2">
1128
+ <Button type="button" variant="outline" onClick={() => void handleRefreshAll()} disabled={isRefreshingPreview || isRestoringSession}>
1129
+ <RefreshCw className="mr-2 size-4" />
1130
+ {t('sync_excel.widget.actions.refreshRun', 'Refresh run status')}
1131
+ </Button>
1132
+ {isRunActive ? (
1133
+ <Button type="button" variant="destructive" onClick={() => void handleCancelRun()} disabled={isCancelling}>
1134
+ {isCancelling ? <Spinner className="mr-2 size-4" /> : <XCircle className="mr-2 size-4" />}
1135
+ {isCancelling
1136
+ ? t('sync_excel.widget.actions.cancelPending', 'Cancelling...')
1137
+ : t('sync_excel.widget.actions.cancel', 'Cancel run')}
1138
+ </Button>
1139
+ ) : null}
1140
+ </div>
1141
+ </CardContent>
1142
+ </Card>
1143
+ ) : null}
1144
+ </div>
1145
+ )
1146
+ }
1147
+
1148
+ function StatusAlert({
1149
+ variant,
1150
+ title,
1151
+ message,
1152
+ }: {
1153
+ variant: 'warning' | 'error' | 'info'
1154
+ title: string
1155
+ message: string
1156
+ }) {
1157
+ return (
1158
+ <Alert variant={variant === 'error' ? 'destructive' : variant}>
1159
+ <AlertTitle>{title}</AlertTitle>
1160
+ <AlertDescription>{message}</AlertDescription>
1161
+ </Alert>
1162
+ )
1163
+ }
1164
+
1165
+ function MetricCard({ label, value }: { label: string; value: string }) {
1166
+ return (
1167
+ <div className="rounded-lg border bg-muted/30 p-4">
1168
+ <div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
1169
+ <div className="mt-2 text-lg font-semibold text-foreground">{value}</div>
1170
+ </div>
1171
+ )
1172
+ }
1173
+
1174
+ function RunStatusBadge({
1175
+ status,
1176
+ t,
1177
+ }: {
1178
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'idle'
1179
+ t: ReturnType<typeof useT>
1180
+ }) {
1181
+ if (status === 'completed') {
1182
+ return (
1183
+ <Badge variant="outline" className="gap-1.5 border-emerald-500/30 bg-emerald-500/10 text-emerald-300">
1184
+ <CheckCircle2 className="size-3.5" />
1185
+ {t('sync_excel.widget.run.status.completed', 'Completed')}
1186
+ </Badge>
1187
+ )
1188
+ }
1189
+
1190
+ if (status === 'failed') {
1191
+ return (
1192
+ <Badge variant="outline" className="gap-1.5 border-red-500/30 bg-red-500/10 text-red-300">
1193
+ <AlertTriangle className="size-3.5" />
1194
+ {t('sync_excel.widget.run.status.failed', 'Failed')}
1195
+ </Badge>
1196
+ )
1197
+ }
1198
+
1199
+ if (status === 'cancelled') {
1200
+ return (
1201
+ <Badge variant="outline" className="gap-1.5 border-zinc-500/30 bg-zinc-500/10 text-zinc-300">
1202
+ <XCircle className="size-3.5" />
1203
+ {t('sync_excel.widget.run.status.cancelled', 'Cancelled')}
1204
+ </Badge>
1205
+ )
1206
+ }
1207
+
1208
+ if (status === 'pending' || status === 'running') {
1209
+ return (
1210
+ <Badge variant="outline" className="gap-1.5 border-blue-500/30 bg-blue-500/10 text-blue-300">
1211
+ <RefreshCw className={cn('size-3.5', status === 'running' ? 'animate-spin' : '')} />
1212
+ {status === 'pending'
1213
+ ? t('sync_excel.widget.run.status.pending', 'Pending')
1214
+ : t('sync_excel.widget.run.status.running', 'Running')}
1215
+ </Badge>
1216
+ )
1217
+ }
1218
+
1219
+ return (
1220
+ <Badge variant="outline" className="gap-1.5 border-zinc-500/30 bg-zinc-500/10 text-zinc-300">
1221
+ {t('sync_excel.widget.run.status.idle', 'Idle')}
1222
+ </Badge>
1223
+ )
1224
+ }