@spree/dashboard-core 0.13.1 → 1.0.0-beta.2

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 (125) hide show
  1. package/README.md +4 -0
  2. package/dist/vite/index.js +17 -3
  3. package/package.json +7 -7
  4. package/src/api-client.ts +643 -0
  5. package/src/components/address-form-dialog.test.ts +25 -0
  6. package/src/components/address-form-dialog.tsx +203 -79
  7. package/src/components/app-shell.tsx +171 -0
  8. package/src/components/app-sidebar.tsx +184 -27
  9. package/src/components/bulk-action-bar.tsx +89 -157
  10. package/src/components/country-combobox.tsx +69 -34
  11. package/src/components/country-state-fields.tsx +43 -11
  12. package/src/components/currency-select.tsx +81 -66
  13. package/src/components/export-button.tsx +62 -5
  14. package/src/components/file-upload-field.tsx +32 -10
  15. package/src/components/import-button.tsx +52 -27
  16. package/src/components/import-wizard-dialog.tsx +710 -0
  17. package/src/components/locale-select.tsx +70 -57
  18. package/src/components/media-picker-sheet.tsx +307 -0
  19. package/src/components/mobile-top-bar.tsx +75 -0
  20. package/src/components/nav-main.tsx +197 -27
  21. package/src/components/page-header.tsx +203 -61
  22. package/src/components/page-tabs.tsx +4 -1
  23. package/src/components/preferences-form.tsx +98 -4
  24. package/src/components/resource-combobox.tsx +52 -4
  25. package/src/components/resource-picker-sheet.tsx +209 -73
  26. package/src/components/resource-table.tsx +383 -127
  27. package/src/components/route-announcer.tsx +64 -0
  28. package/src/components/section-heading.tsx +95 -0
  29. package/src/components/settings-sidebar.tsx +290 -60
  30. package/src/components/sheet-top-bar.tsx +44 -0
  31. package/src/components/sidebar-search.tsx +83 -0
  32. package/src/components/sidebar-user.tsx +213 -0
  33. package/src/components/skip-link.tsx +36 -0
  34. package/src/components/store-date-picker.tsx +6 -2
  35. package/src/components/store-switcher.tsx +19 -14
  36. package/src/components/table-toolbar.tsx +1357 -440
  37. package/src/components/tag-combobox.tsx +12 -4
  38. package/src/components/top-bar-breadcrumbs.tsx +235 -0
  39. package/src/components/wizard-close-guard.test.ts +54 -0
  40. package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
  41. package/src/hooks/use-command-palette.tsx +13 -0
  42. package/src/hooks/use-countries.test.ts +56 -0
  43. package/src/hooks/use-countries.ts +51 -5
  44. package/src/hooks/use-direct-upload.ts +16 -3
  45. package/src/hooks/use-export.ts +51 -16
  46. package/src/hooks/use-import.ts +241 -16
  47. package/src/hooks/use-package-types.ts +132 -0
  48. package/src/hooks/use-resource-mutation.ts +74 -13
  49. package/src/hooks/use-stock-locations.ts +131 -0
  50. package/src/hooks/use-switch-admin-locale.ts +2 -2
  51. package/src/hooks/use-visual-viewport-offset.ts +61 -0
  52. package/src/index.ts +45 -1
  53. package/src/lib/cancel-order-form.ts +27 -0
  54. package/src/lib/create-action-registry.ts +278 -0
  55. package/src/lib/date-presets.test.ts +86 -0
  56. package/src/lib/date-presets.ts +98 -0
  57. package/src/lib/delivery-form.ts +15 -0
  58. package/src/lib/docs.ts +32 -0
  59. package/src/lib/download.ts +13 -1
  60. package/src/lib/filters-to-ransack.test.ts +113 -0
  61. package/src/lib/filters-to-ransack.ts +22 -1
  62. package/src/lib/form-errors.ts +122 -10
  63. package/src/lib/form-mappers.ts +76 -0
  64. package/src/lib/fulfillment-items.test.ts +213 -0
  65. package/src/lib/fulfillment-items.ts +171 -0
  66. package/src/lib/i18n.ts +19 -11
  67. package/src/lib/import-types.ts +25 -0
  68. package/src/lib/label-upload-form.ts +22 -0
  69. package/src/lib/nav-registry.ts +71 -3
  70. package/src/lib/permissions.ts +46 -1
  71. package/src/lib/progress.ts +18 -0
  72. package/src/lib/query-keys.ts +13 -13
  73. package/src/lib/sample-csv.ts +9 -6
  74. package/src/lib/search-registry.ts +6 -2
  75. package/src/lib/settings-nav-registry.ts +104 -11
  76. package/src/lib/table-registry.ts +50 -0
  77. package/src/lib/type-labels.test.ts +68 -0
  78. package/src/lib/type-labels.ts +78 -0
  79. package/src/locales/ar.json +1889 -517
  80. package/src/locales/de.json +1884 -517
  81. package/src/locales/en.json +1886 -519
  82. package/src/locales/fr.json +1884 -517
  83. package/src/locales/pl.json +1913 -517
  84. package/src/locales/zh-CN.json +1883 -517
  85. package/src/pages/package-types.tsx +484 -0
  86. package/src/pages/settings-index.tsx +112 -0
  87. package/src/pages/stock-locations.tsx +674 -0
  88. package/src/plugin.ts +29 -0
  89. package/src/products/add-video-dialog.tsx +100 -0
  90. package/src/products/bulk-variants-dialog.tsx +301 -0
  91. package/src/products/carton-size.test.ts +51 -0
  92. package/src/products/carton-size.ts +20 -0
  93. package/src/products/currency-parts.ts +21 -0
  94. package/src/products/inventory-section.tsx +259 -0
  95. package/src/products/media-edit-sheet.tsx +336 -0
  96. package/src/products/normalize-customs.test.ts +39 -0
  97. package/src/products/normalize-customs.ts +27 -0
  98. package/src/products/normalize-money.test.ts +58 -0
  99. package/src/products/normalize-money.ts +73 -0
  100. package/src/products/normalize-quantity.test.ts +32 -0
  101. package/src/products/normalize-quantity.ts +31 -0
  102. package/src/products/product-bulk-price-editor.tsx +165 -0
  103. package/src/products/product-form-cards.tsx +1167 -0
  104. package/src/products/product-form-mapping.ts +212 -0
  105. package/src/products/product-schema.ts +318 -0
  106. package/src/products/product-types.ts +130 -0
  107. package/src/products/use-product-form-data.ts +273 -0
  108. package/src/products/variant-edit-sheet.tsx +725 -0
  109. package/src/products/variants-matrix.test.ts +154 -0
  110. package/src/products/variants-matrix.ts +244 -0
  111. package/src/products/variants-options-builder.tsx +571 -0
  112. package/src/products/variants-section.tsx +535 -0
  113. package/src/products/video-url.test.ts +71 -0
  114. package/src/products/video-url.ts +83 -0
  115. package/src/providers/auth-provider.tsx +70 -12
  116. package/src/providers/permission-provider.tsx +68 -28
  117. package/src/providers/sticky-header-provider.tsx +134 -0
  118. package/src/providers/store-provider.tsx +36 -3
  119. package/src/providers/tenant-provider.tsx +33 -0
  120. package/src/schemas/package-type.ts +95 -0
  121. package/src/schemas/stock-location.ts +126 -0
  122. package/src/tables/package-types.tsx +74 -0
  123. package/src/tables/stock-locations.tsx +124 -0
  124. package/src/vite/index.ts +40 -7
  125. package/src/components/top-bar.tsx +0 -209
@@ -0,0 +1,710 @@
1
+ import {
2
+ Badge,
3
+ Button,
4
+ Card,
5
+ CardContent,
6
+ CardHeader,
7
+ CardTitle,
8
+ Dialog,
9
+ DialogBody,
10
+ DialogContent,
11
+ DialogFooter,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ Progress,
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ Skeleton,
21
+ StatusBadge,
22
+ Table,
23
+ TableBody,
24
+ TableCell,
25
+ TableHead,
26
+ TableHeader,
27
+ TableRow,
28
+ useConfirm,
29
+ WizardProgress,
30
+ } from '@spree/dashboard-ui'
31
+ import {
32
+ AlertTriangleIcon,
33
+ CheckCircle2Icon,
34
+ ChevronLeftIcon,
35
+ ChevronRightIcon,
36
+ DownloadIcon,
37
+ RotateCcwIcon,
38
+ XIcon,
39
+ } from '@spree/dashboard-ui/icons'
40
+ import { useEffect, useRef, useState } from 'react'
41
+ import { useTranslation } from 'react-i18next'
42
+ import type { PanelImport, PanelImportRow } from '../api-client'
43
+ import {
44
+ isImportActive,
45
+ useCompleteMapping,
46
+ useDownloadImportOriginal,
47
+ useImport,
48
+ useImportRows,
49
+ useRetryFailedRows,
50
+ } from '../hooks/use-import'
51
+ import { importTypeLabel } from '../lib/import-types'
52
+
53
+ const NOT_MAPPED = '__not_mapped__'
54
+
55
+ interface ImportWizardDialogProps {
56
+ /** Prefixed id of the import to drive; `null` keeps the dialog closed. */
57
+ importId: string | null
58
+ onClose: () => void
59
+ /**
60
+ * Sends the user to the records the import wrote, once it has finished.
61
+ *
62
+ * Injected rather than routed here: each panel files its catalog under its
63
+ * own route tree, and a component that navigated by itself would only work
64
+ * in the one it was written for. Omit it and the button is not rendered —
65
+ * the import is still complete, there is simply nowhere named to go.
66
+ */
67
+ onViewRecords?: (type: string | null, imp: PanelImport) => void
68
+ }
69
+
70
+ /**
71
+ * Full-window wizard for one import — mapping → progress → results as states
72
+ * of a single edge-to-edge dialog (same shell as the bulk price editor).
73
+ * "Deeper into this thing", not "leave this thing": the page behind keeps its
74
+ * state, and closing mid-processing is safe — the import continues server-side
75
+ * and reopens from the history page (or the same `?import=` URL).
76
+ */
77
+ export function ImportWizardDialog({ importId, onClose, onViewRecords }: ImportWizardDialogProps) {
78
+ const { t } = useTranslation()
79
+ const confirm = useConfirm()
80
+ // Only the mapping step holds anything unsaved. Once the import is running
81
+ // the work is server-side, so closing then is safe and asking would nag.
82
+ const [mappingUnsaved, setMappingUnsaved] = useState(false)
83
+ async function requestClose() {
84
+ if (mappingUnsaved) {
85
+ const discard = await confirm({
86
+ title: t('admin.components.wizard.discard_title'),
87
+ message: t('admin.components.wizard.discard_message'),
88
+ confirmLabel: t('admin.components.wizard.discard_confirm'),
89
+ cancelLabel: t('admin.components.wizard.discard_cancel'),
90
+ variant: 'destructive',
91
+ })
92
+ if (!discard) return
93
+ }
94
+ onClose()
95
+ }
96
+
97
+ return (
98
+ <Dialog
99
+ open={!!importId}
100
+ onOpenChange={(next, details) => {
101
+ if (next) return
102
+ if (details?.reason === 'escape-key' || details?.reason === 'outside-press') {
103
+ void requestClose()
104
+ return
105
+ }
106
+ onClose()
107
+ }}
108
+ modal
109
+ >
110
+ <DialogContent
111
+ // Edge-to-edge minus a 3-unit gutter — see BulkPriceEditorDialog for
112
+ // why every inset/translate/max is overridden.
113
+ className="!inset-3 !w-auto !max-w-none !translate-x-0 !translate-y-0 flex flex-col p-0"
114
+ style={{ maxHeight: 'none' }}
115
+ showCloseButton={false}
116
+ >
117
+ {importId && (
118
+ <ImportWizard
119
+ importId={importId}
120
+ onClose={requestClose}
121
+ onViewRecords={onViewRecords}
122
+ onMappingDirtyChange={setMappingUnsaved}
123
+ />
124
+ )}
125
+ </DialogContent>
126
+ </Dialog>
127
+ )
128
+ }
129
+
130
+ function ImportWizard({
131
+ importId,
132
+ onClose,
133
+ onViewRecords,
134
+ onMappingDirtyChange,
135
+ }: {
136
+ importId: string
137
+ onClose: () => void
138
+ onViewRecords?: (type: string | null, imp: PanelImport) => void
139
+ /** Reports whether the mapping has edits the dialog should protect. */
140
+ onMappingDirtyChange?: (dirty: boolean) => void
141
+ }) {
142
+ const { t } = useTranslation()
143
+ const { data: imp, isLoading, isError, refetch } = useImport(importId)
144
+ const downloadOriginal = useDownloadImportOriginal()
145
+ // Explicit retry marker: the processing card must not infer a retry pass
146
+ // from row totals (they look identical in the last poll tick of a first
147
+ // pass that ends with failures).
148
+ const [retryRequested, setRetryRequested] = useState(false)
149
+ const completeMapping = useCompleteMapping(importId)
150
+ const [assignments, setAssignments] = useState<MappingAssignments>({})
151
+
152
+ // Seeded once per import. The query is stale immediately, so a refetch on
153
+ // window focus hands back a new object; re-seeding on that would throw away
154
+ // a mapping the merchant is part-way through.
155
+ const seededFor = useRef<string | null>(null)
156
+ useEffect(() => {
157
+ if (!imp || seededFor.current === imp.id) return
158
+ seededFor.current = imp.id
159
+ setAssignments(initialAssignments(imp))
160
+ }, [imp])
161
+
162
+ const isMapping = imp?.status === 'mapping'
163
+
164
+ useEffect(() => {
165
+ if (!onMappingDirtyChange) return
166
+ const edited =
167
+ !!imp &&
168
+ imp.status === 'mapping' &&
169
+ Object.entries(initialAssignments(imp)).some(
170
+ ([field, column]) => (assignments[field] ?? null) !== (column ?? null),
171
+ )
172
+ onMappingDirtyChange(edited)
173
+ }, [imp, assignments, onMappingDirtyChange])
174
+ const isCompleted = imp?.status === 'completed'
175
+ const missingRequired = imp && isMapping ? missingRequiredFields(imp, assignments) : []
176
+ const retryMutation = useRetryFailedRows(importId)
177
+
178
+ function handleStartImport() {
179
+ if (!imp) return
180
+ completeMapping.mutate({
181
+ mappings: imp.schema_fields.map((field) => ({
182
+ schema_field: field.name,
183
+ file_column: assignments[field.name] ?? null,
184
+ })),
185
+ })
186
+ }
187
+
188
+ const showFailedRows =
189
+ !!imp && imp.failed_rows_count > 0 && (isImportActive(imp.status) || imp.status === 'completed')
190
+
191
+ return (
192
+ <>
193
+ <DialogHeader className="h-14 shrink-0 flex-row items-center gap-3 space-y-0 border-b border-border-subtle bg-background px-4 py-0">
194
+ <div className="flex min-w-0 items-center gap-3">
195
+ <DialogTitle className="truncate text-base">
196
+ {t('admin.imports.wizard_title')}
197
+ </DialogTitle>
198
+ {imp && (
199
+ <>
200
+ <span aria-hidden className="h-4 w-px shrink-0 bg-border" />
201
+ <span className="truncate text-muted-foreground text-sm">
202
+ {`${importTypeLabel(imp.type)} · ${imp.number}`}
203
+ </span>
204
+ </>
205
+ )}
206
+ </div>
207
+ <div className="ms-auto flex shrink-0 items-center gap-2">
208
+ {imp && (
209
+ <StatusBadge status={imp.status} label={t(`admin.imports.status.${imp.status}`)} />
210
+ )}
211
+ {/* Keyed on the filename rather than the URL: a client that serves
212
+ the file from its own endpoint may send no `original_file_url`
213
+ (the download hook falls back to `imports().downloadUrl`), but an
214
+ import with nothing attached must still not offer a download —
215
+ that endpoint answers 422. */}
216
+ {imp?.original_filename && (
217
+ <Button
218
+ type="button"
219
+ size="icon-sm"
220
+ variant="ghost"
221
+ onClick={() => downloadOriginal.mutate(imp)}
222
+ disabled={downloadOriginal.isPending}
223
+ aria-label={t('admin.imports.download_original')}
224
+ >
225
+ <DownloadIcon />
226
+ </Button>
227
+ )}
228
+ <Button
229
+ type="button"
230
+ size="icon-sm"
231
+ variant="ghost"
232
+ onClick={onClose}
233
+ aria-label={t('admin.actions.close')}
234
+ >
235
+ <XIcon />
236
+ </Button>
237
+ </div>
238
+ </DialogHeader>
239
+
240
+ {/* The body itself does not scroll: the rail stays put while the step's
241
+ own column scrolls, so a long mapping table never carries the
242
+ progress list off-screen. */}
243
+ <DialogBody className="flex min-h-0 flex-1 gap-8 overflow-hidden p-0">
244
+ <StepIndicator status={imp?.status ?? 'mapping'} />
245
+ <div className="flex min-w-0 flex-1 flex-col gap-4 overflow-y-auto py-8 pe-8 ps-8 md:ps-0">
246
+ {isError ? (
247
+ <div className="flex flex-col items-center gap-3 py-12 text-center">
248
+ <AlertTriangleIcon className="size-8 text-destructive" />
249
+ <p className="text-muted-foreground text-sm">{t('admin.imports.load_failed')}</p>
250
+ <Button variant="outline" onClick={() => refetch()}>
251
+ {t('admin.imports.try_again')}
252
+ </Button>
253
+ </div>
254
+ ) : isLoading || !imp ? (
255
+ <>
256
+ <Skeleton className="h-6 w-64" />
257
+ <Skeleton className="h-40 w-full" />
258
+ </>
259
+ ) : (
260
+ <>
261
+ {imp.status === 'mapping' && (
262
+ <MappingStep
263
+ imp={imp}
264
+ assignments={assignments}
265
+ setAssignments={setAssignments}
266
+ error={completeMapping.error}
267
+ />
268
+ )}
269
+ {isImportActive(imp.status) && (
270
+ <ProcessingCard imp={imp} retryPass={retryRequested} />
271
+ )}
272
+ {imp.status === 'completed' && <ResultsCard imp={imp} />}
273
+ {imp.status === 'failed' && <FailedCard imp={imp} />}
274
+ {showFailedRows && <FailedRowsCard imp={imp} />}
275
+ </>
276
+ )}
277
+ </div>
278
+ </DialogBody>
279
+
280
+ {/* Every step's action lives here rather than at the end of its card:
281
+ a long mapping table or failed-rows list would otherwise push the
282
+ button below the fold. */}
283
+ {(isMapping || isCompleted) && imp && (
284
+ <DialogFooter className="h-16 shrink-0 flex-row items-center justify-between border-border-subtle bg-background px-4 py-0 sm:justify-between">
285
+ <span className="text-muted-foreground text-sm">
286
+ {isMapping && missingRequired.length > 0
287
+ ? t('admin.imports.mapping.missing_required', {
288
+ fields: missingRequired.map((f) => f.label).join(', '),
289
+ })
290
+ : null}
291
+ </span>
292
+
293
+ {isMapping ? (
294
+ <Button
295
+ onClick={handleStartImport}
296
+ disabled={missingRequired.length > 0 || completeMapping.isPending}
297
+ >
298
+ {completeMapping.isPending
299
+ ? t('admin.imports.mapping.starting')
300
+ : t('admin.imports.mapping.start')}
301
+ </Button>
302
+ ) : (
303
+ <div className="flex items-center gap-2">
304
+ {imp.failed_rows_count > 0 && (
305
+ <Button
306
+ variant="outline"
307
+ onClick={() =>
308
+ retryMutation.mutate(undefined, { onSuccess: () => setRetryRequested(true) })
309
+ }
310
+ disabled={retryMutation.isPending}
311
+ >
312
+ <RotateCcwIcon className="size-4" />
313
+ {retryMutation.isPending
314
+ ? t('admin.imports.results.retrying')
315
+ : t('admin.imports.results.retry_failed', { failed: imp.failed_rows_count })}
316
+ </Button>
317
+ )}
318
+ {onViewRecords && (
319
+ <Button
320
+ onClick={() => {
321
+ onClose()
322
+ onViewRecords(imp.type, imp)
323
+ }}
324
+ >
325
+ {t('admin.imports.results.view_records', { type: importTypeLabel(imp.type) })}
326
+ </Button>
327
+ )}
328
+ </div>
329
+ )}
330
+ </DialogFooter>
331
+ )}
332
+ </>
333
+ )
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Step indicator — Map fields → Process rows → Complete, mirroring the legacy
338
+ // wizard's three steps.
339
+ // ---------------------------------------------------------------------------
340
+
341
+ function StepIndicator({ status }: { status: string }) {
342
+ const { t } = useTranslation()
343
+
344
+ // A failed import stays on the processing step — it never completed.
345
+ const activeIndex =
346
+ status === 'mapping' ? 0 : isImportActive(status) || status === 'failed' ? 1 : 2
347
+ const steps = ['map_fields', 'process_rows', 'complete'] as const
348
+
349
+ return (
350
+ <WizardProgress
351
+ className="hidden w-56 shrink-0 py-8 ps-8 md:flex"
352
+ current={steps[activeIndex]}
353
+ steps={steps.map((step) => ({ key: step, label: t(`admin.imports.steps.${step}`) }))}
354
+ />
355
+ )
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // Mapping step
360
+ // ---------------------------------------------------------------------------
361
+
362
+ /** Which schema field each CSV column feeds, keyed by field name. */
363
+ export type MappingAssignments = Record<string, string | null>
364
+
365
+ export function initialAssignments(imp: PanelImport): MappingAssignments {
366
+ return Object.fromEntries(imp.mappings.map((m) => [m.schema_field, m.file_column]))
367
+ }
368
+
369
+ export function missingRequiredFields(imp: PanelImport, assignments: MappingAssignments) {
370
+ return imp.schema_fields.filter((f) => f.required && !assignments[f.name])
371
+ }
372
+
373
+ /**
374
+ * The mapping table. Assignments live in the wizard rather than here, because
375
+ * the action that submits them sits in the wizard's footer — a long column
376
+ * list would otherwise bury the button below the fold.
377
+ */
378
+ function MappingStep({
379
+ imp,
380
+ assignments,
381
+ setAssignments,
382
+ error,
383
+ }: {
384
+ imp: PanelImport
385
+ assignments: MappingAssignments
386
+ setAssignments: React.Dispatch<React.SetStateAction<MappingAssignments>>
387
+ /** Failure from the footer's submit, rendered beside the fields it concerns. */
388
+ error?: Error | null
389
+ }) {
390
+ const { t } = useTranslation()
391
+
392
+ const headerOptions = (current: string | null) => [
393
+ { value: NOT_MAPPED, label: t('admin.imports.mapping.not_mapped'), disabled: false },
394
+ ...imp.csv_headers.map((header) => ({
395
+ value: header,
396
+ label: header,
397
+ // A file column can feed only one field — mirror the server's
398
+ // uniqueness validation instead of 422-ing on submit.
399
+ disabled: header !== current && Object.values(assignments).includes(header),
400
+ })),
401
+ ]
402
+
403
+ return (
404
+ <Card>
405
+ <CardHeader>
406
+ <CardTitle>{t('admin.imports.mapping.title')}</CardTitle>
407
+ <p className="text-muted-foreground text-sm">
408
+ {t('admin.imports.mapping.description', { type: importTypeLabel(imp.type) })}
409
+ </p>
410
+ </CardHeader>
411
+ <CardContent className="flex flex-col gap-4 p-0">
412
+ <div className="overflow-x-auto">
413
+ <Table>
414
+ <TableHeader>
415
+ <TableRow>
416
+ <TableHead>{t('admin.imports.mapping.field')}</TableHead>
417
+ <TableHead>{t('admin.imports.mapping.file_column')}</TableHead>
418
+ <TableHead>{t('admin.imports.mapping.sample')}</TableHead>
419
+ </TableRow>
420
+ </TableHeader>
421
+ <TableBody>
422
+ {imp.schema_fields.map((field) => {
423
+ const current = assignments[field.name] ?? null
424
+ const options = headerOptions(current)
425
+ const sample = current ? (imp.sample_row[current] ?? '') : ''
426
+
427
+ return (
428
+ <TableRow key={field.name}>
429
+ <TableCell>
430
+ <span className="inline-flex items-center gap-2">
431
+ {field.label}
432
+ {field.required && (
433
+ <Badge variant="outline">{t('admin.imports.mapping.required')}</Badge>
434
+ )}
435
+ </span>
436
+ </TableCell>
437
+ <TableCell className="w-72">
438
+ <Select
439
+ items={options}
440
+ value={current ?? NOT_MAPPED}
441
+ onValueChange={(value) =>
442
+ setAssignments((prev) => ({
443
+ ...prev,
444
+ [field.name]: value === NOT_MAPPED ? null : (value as string),
445
+ }))
446
+ }
447
+ >
448
+ <SelectTrigger className="w-full">
449
+ <SelectValue />
450
+ </SelectTrigger>
451
+ <SelectContent>
452
+ {options.map((option) => (
453
+ <SelectItem
454
+ key={option.value}
455
+ value={option.value}
456
+ disabled={option.disabled}
457
+ >
458
+ {option.label}
459
+ </SelectItem>
460
+ ))}
461
+ </SelectContent>
462
+ </Select>
463
+ </TableCell>
464
+ <TableCell className="max-w-48 truncate text-muted-foreground text-sm">
465
+ {sample}
466
+ </TableCell>
467
+ </TableRow>
468
+ )
469
+ })}
470
+ </TableBody>
471
+ </Table>
472
+ </div>
473
+
474
+ {error && (
475
+ <div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-destructive text-sm">
476
+ <AlertTriangleIcon className="size-4 shrink-0" />
477
+ <span>{error.message}</span>
478
+ </div>
479
+ )}
480
+ </CardContent>
481
+ </Card>
482
+ )
483
+ }
484
+
485
+ // ---------------------------------------------------------------------------
486
+ // Processing
487
+ // ---------------------------------------------------------------------------
488
+
489
+ // `retryPass` is the wizard's explicit marker that the user triggered a
490
+ // failed-rows retry: every row is already terminal then, so the bar would sit
491
+ // at 100% — show the shrinking failed count instead.
492
+ function ProcessingCard({ imp, retryPass }: { imp: PanelImport; retryPass: boolean }) {
493
+ const { t } = useTranslation()
494
+
495
+ const total = imp.rows_count
496
+ const processed = imp.completed_rows_count + imp.failed_rows_count
497
+ const preparing = total === 0
498
+ const percent = preparing ? 0 : Math.min(100, Math.round((processed / total) * 100))
499
+
500
+ return (
501
+ <Card>
502
+ <CardHeader>
503
+ <CardTitle>{t('admin.imports.processing.title')}</CardTitle>
504
+ </CardHeader>
505
+ <CardContent className="flex flex-col gap-3">
506
+ {/* value={null} = Base UI's indeterminate state while rows are created */}
507
+ <Progress value={preparing ? null : percent} />
508
+
509
+ <p className="text-sm">
510
+ {preparing ? (
511
+ t('admin.imports.processing.preparing')
512
+ ) : retryPass && imp.failed_rows_count > 0 ? (
513
+ t('admin.imports.processing.retrying', { remaining: imp.failed_rows_count })
514
+ ) : (
515
+ <>
516
+ {t('admin.imports.processing.progress', {
517
+ processed: processed.toLocaleString(),
518
+ total: total.toLocaleString(),
519
+ })}
520
+ {imp.failed_rows_count > 0 && (
521
+ <span className="text-destructive">
522
+ {' · '}
523
+ {t('admin.imports.processing.failed_count', { failed: imp.failed_rows_count })}
524
+ </span>
525
+ )}
526
+ </>
527
+ )}
528
+ </p>
529
+
530
+ <p className="text-muted-foreground text-sm">{t('admin.imports.processing.note')}</p>
531
+ </CardContent>
532
+ </Card>
533
+ )
534
+ }
535
+
536
+ // ---------------------------------------------------------------------------
537
+ // Results / file-level failure
538
+ // ---------------------------------------------------------------------------
539
+
540
+ function ResultsCard({ imp }: { imp: PanelImport }) {
541
+ const { t } = useTranslation()
542
+
543
+ return (
544
+ <Card>
545
+ <CardContent className="flex flex-col items-center gap-3 py-8 text-center">
546
+ <CheckCircle2Icon className="size-10 text-success" />
547
+ <p className="font-medium">{t('admin.imports.results.completed_title')}</p>
548
+ <p className="text-muted-foreground text-sm">
549
+ {t('admin.imports.results.summary', {
550
+ completed: imp.completed_rows_count.toLocaleString(),
551
+ failed: imp.failed_rows_count.toLocaleString(),
552
+ })}
553
+ </p>
554
+ </CardContent>
555
+ </Card>
556
+ )
557
+ }
558
+
559
+ function FailedCard({ imp }: { imp: PanelImport }) {
560
+ const { t } = useTranslation()
561
+
562
+ return (
563
+ <Card>
564
+ <CardContent className="flex flex-col items-center gap-3 py-8 text-center">
565
+ <AlertTriangleIcon className="size-10 text-destructive" />
566
+ <p className="font-medium">{t('admin.imports.failed.title')}</p>
567
+ <p className="text-muted-foreground text-sm">{t('admin.imports.failed.description')}</p>
568
+ {imp.processing_errors && (
569
+ <code className="max-w-full overflow-x-auto rounded-md bg-muted p-3 text-left text-xs">
570
+ {imp.processing_errors}
571
+ </code>
572
+ )}
573
+ </CardContent>
574
+ </Card>
575
+ )
576
+ }
577
+
578
+ // ---------------------------------------------------------------------------
579
+ // Failed rows report
580
+ // ---------------------------------------------------------------------------
581
+
582
+ function FailedRowsCard({ imp }: { imp: PanelImport }) {
583
+ const { t } = useTranslation()
584
+ const [page, setPage] = useState(1)
585
+ const { data, isPending, isError, refetch } = useImportRows(
586
+ imp.id,
587
+ { status_eq: 'failed', sort: 'row_number', page },
588
+ { poll: isImportActive(imp.status) },
589
+ )
590
+
591
+ const rows = data?.data ?? []
592
+ const meta = data?.meta
593
+
594
+ // Retrying shrinks the failure set — an out-of-range page would otherwise
595
+ // return an empty list with no way back.
596
+ useEffect(() => {
597
+ if (meta && page > Math.max(meta.pages, 1)) {
598
+ setPage(Math.max(meta.pages, 1))
599
+ }
600
+ }, [meta, page])
601
+
602
+ // Distinguish "nothing failed" (hide the card) from "couldn't load" and
603
+ // "still loading" — the report must not silently disappear.
604
+ if (!isPending && !isError && rows.length === 0) return null
605
+
606
+ return (
607
+ <Card>
608
+ <CardHeader>
609
+ <CardTitle>{t('admin.imports.failed_rows.title')}</CardTitle>
610
+ </CardHeader>
611
+ <CardContent className="flex flex-col gap-3 p-0">
612
+ {isError ? (
613
+ <div className="flex items-center gap-3 p-4">
614
+ <AlertTriangleIcon className="size-4 shrink-0 text-destructive" />
615
+ <span className="text-muted-foreground text-sm">{t('admin.imports.load_failed')}</span>
616
+ <Button variant="outline" size="sm" onClick={() => refetch()}>
617
+ {t('admin.imports.try_again')}
618
+ </Button>
619
+ </div>
620
+ ) : isPending ? (
621
+ <div className="flex flex-col gap-2 p-4">
622
+ <Skeleton className="h-5 w-full" />
623
+ <Skeleton className="h-5 w-2/3" />
624
+ </div>
625
+ ) : (
626
+ <>
627
+ <div className="overflow-x-auto">
628
+ <Table>
629
+ <TableHeader>
630
+ <TableRow>
631
+ <TableHead className="w-16">
632
+ {t('admin.imports.failed_rows.row_number')}
633
+ </TableHead>
634
+ <TableHead>{t('admin.imports.failed_rows.error')}</TableHead>
635
+ <TableHead className="w-32" />
636
+ </TableRow>
637
+ </TableHeader>
638
+ <TableBody>
639
+ {rows.map((row) => (
640
+ <FailedRow key={row.id} row={row} />
641
+ ))}
642
+ </TableBody>
643
+ </Table>
644
+ </div>
645
+
646
+ {meta && meta.pages > 1 && (
647
+ <div className="flex items-center justify-end gap-2 px-4 pb-4">
648
+ <Button
649
+ variant="outline"
650
+ size="sm"
651
+ disabled={page <= 1}
652
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
653
+ aria-label={t('admin.imports.failed_rows.prev_page')}
654
+ >
655
+ <ChevronLeftIcon className="size-4" />
656
+ </Button>
657
+ <span className="text-muted-foreground text-sm">
658
+ {page} / {meta.pages}
659
+ </span>
660
+ <Button
661
+ variant="outline"
662
+ size="sm"
663
+ disabled={page >= meta.pages}
664
+ onClick={() => setPage((p) => p + 1)}
665
+ aria-label={t('admin.imports.failed_rows.next_page')}
666
+ >
667
+ <ChevronRightIcon className="size-4" />
668
+ </Button>
669
+ </div>
670
+ )}
671
+ </>
672
+ )}
673
+ </CardContent>
674
+ </Card>
675
+ )
676
+ }
677
+
678
+ function FailedRow({ row }: { row: PanelImportRow }) {
679
+ const { t } = useTranslation()
680
+ const [expanded, setExpanded] = useState(false)
681
+
682
+ return (
683
+ <>
684
+ <TableRow>
685
+ <TableCell className="text-muted-foreground">{row.row_number}</TableCell>
686
+ <TableCell className="text-destructive text-sm">
687
+ {String(row.validation_errors ?? '')}
688
+ </TableCell>
689
+ <TableCell className="text-right">
690
+ <Button variant="ghost" size="sm" onClick={() => setExpanded((e) => !e)}>
691
+ {expanded
692
+ ? t('admin.imports.failed_rows.hide_data')
693
+ : t('admin.imports.failed_rows.view_data')}
694
+ </Button>
695
+ </TableCell>
696
+ </TableRow>
697
+ {expanded && (
698
+ <TableRow>
699
+ <TableCell colSpan={3}>
700
+ <div className="overflow-x-auto rounded-md bg-muted p-3">
701
+ <code className="whitespace-pre-wrap text-xs">
702
+ {JSON.stringify(row.data, null, 2)}
703
+ </code>
704
+ </div>
705
+ </TableCell>
706
+ </TableRow>
707
+ )}
708
+ </>
709
+ )
710
+ }