@spree/dashboard-core 0.13.1 → 1.0.0-beta.1
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/README.md +4 -0
- package/dist/vite/index.js +17 -3
- package/package.json +7 -7
- package/src/api-client.ts +643 -0
- package/src/components/address-form-dialog.test.ts +25 -0
- package/src/components/address-form-dialog.tsx +203 -79
- package/src/components/app-shell.tsx +171 -0
- package/src/components/app-sidebar.tsx +184 -27
- package/src/components/bulk-action-bar.tsx +89 -157
- package/src/components/country-combobox.tsx +69 -34
- package/src/components/country-state-fields.tsx +43 -11
- package/src/components/currency-select.tsx +81 -66
- package/src/components/export-button.tsx +62 -5
- package/src/components/file-upload-field.tsx +32 -10
- package/src/components/import-button.tsx +52 -27
- package/src/components/import-wizard-dialog.tsx +710 -0
- package/src/components/locale-select.tsx +70 -57
- package/src/components/media-picker-sheet.tsx +307 -0
- package/src/components/mobile-top-bar.tsx +75 -0
- package/src/components/nav-main.tsx +197 -27
- package/src/components/page-header.tsx +203 -61
- package/src/components/page-tabs.tsx +4 -1
- package/src/components/preferences-form.tsx +98 -4
- package/src/components/resource-combobox.tsx +52 -4
- package/src/components/resource-picker-sheet.tsx +209 -73
- package/src/components/resource-table.tsx +383 -127
- package/src/components/route-announcer.tsx +64 -0
- package/src/components/section-heading.tsx +95 -0
- package/src/components/settings-sidebar.tsx +290 -60
- package/src/components/sheet-top-bar.tsx +44 -0
- package/src/components/sidebar-search.tsx +83 -0
- package/src/components/sidebar-user.tsx +213 -0
- package/src/components/skip-link.tsx +36 -0
- package/src/components/store-date-picker.tsx +6 -2
- package/src/components/store-switcher.tsx +19 -14
- package/src/components/table-toolbar.tsx +1357 -440
- package/src/components/tag-combobox.tsx +12 -4
- package/src/components/top-bar-breadcrumbs.tsx +235 -0
- package/src/components/wizard-close-guard.test.ts +54 -0
- package/src/hooks/use-auto-collapse-sidebar.ts +57 -0
- package/src/hooks/use-command-palette.tsx +13 -0
- package/src/hooks/use-countries.test.ts +56 -0
- package/src/hooks/use-countries.ts +51 -5
- package/src/hooks/use-direct-upload.ts +16 -3
- package/src/hooks/use-export.ts +51 -16
- package/src/hooks/use-import.ts +241 -16
- package/src/hooks/use-package-types.ts +132 -0
- package/src/hooks/use-resource-mutation.ts +74 -13
- package/src/hooks/use-stock-locations.ts +131 -0
- package/src/hooks/use-switch-admin-locale.ts +2 -2
- package/src/hooks/use-visual-viewport-offset.ts +61 -0
- package/src/index.ts +45 -1
- package/src/lib/cancel-order-form.ts +27 -0
- package/src/lib/create-action-registry.ts +278 -0
- package/src/lib/date-presets.test.ts +86 -0
- package/src/lib/date-presets.ts +98 -0
- package/src/lib/delivery-form.ts +15 -0
- package/src/lib/docs.ts +32 -0
- package/src/lib/download.ts +13 -1
- package/src/lib/filters-to-ransack.test.ts +113 -0
- package/src/lib/filters-to-ransack.ts +22 -1
- package/src/lib/form-errors.ts +122 -10
- package/src/lib/form-mappers.ts +76 -0
- package/src/lib/fulfillment-items.test.ts +213 -0
- package/src/lib/fulfillment-items.ts +171 -0
- package/src/lib/i18n.ts +19 -11
- package/src/lib/import-types.ts +25 -0
- package/src/lib/label-upload-form.ts +22 -0
- package/src/lib/nav-registry.ts +71 -3
- package/src/lib/permissions.ts +46 -1
- package/src/lib/progress.ts +18 -0
- package/src/lib/query-keys.ts +13 -13
- package/src/lib/sample-csv.ts +9 -6
- package/src/lib/search-registry.ts +6 -2
- package/src/lib/settings-nav-registry.ts +104 -11
- package/src/lib/table-registry.ts +50 -0
- package/src/lib/type-labels.test.ts +68 -0
- package/src/lib/type-labels.ts +78 -0
- package/src/locales/ar.json +1889 -517
- package/src/locales/de.json +1884 -517
- package/src/locales/en.json +1886 -519
- package/src/locales/fr.json +1884 -517
- package/src/locales/pl.json +1913 -517
- package/src/locales/zh-CN.json +1883 -517
- package/src/pages/package-types.tsx +484 -0
- package/src/pages/settings-index.tsx +112 -0
- package/src/pages/stock-locations.tsx +674 -0
- package/src/plugin.ts +29 -0
- package/src/products/add-video-dialog.tsx +100 -0
- package/src/products/bulk-variants-dialog.tsx +301 -0
- package/src/products/carton-size.test.ts +51 -0
- package/src/products/carton-size.ts +20 -0
- package/src/products/currency-parts.ts +21 -0
- package/src/products/inventory-section.tsx +259 -0
- package/src/products/media-edit-sheet.tsx +336 -0
- package/src/products/normalize-customs.test.ts +39 -0
- package/src/products/normalize-customs.ts +27 -0
- package/src/products/normalize-money.test.ts +58 -0
- package/src/products/normalize-money.ts +73 -0
- package/src/products/normalize-quantity.test.ts +32 -0
- package/src/products/normalize-quantity.ts +31 -0
- package/src/products/product-bulk-price-editor.tsx +165 -0
- package/src/products/product-form-cards.tsx +1167 -0
- package/src/products/product-form-mapping.ts +212 -0
- package/src/products/product-schema.ts +318 -0
- package/src/products/product-types.ts +130 -0
- package/src/products/use-product-form-data.ts +273 -0
- package/src/products/variant-edit-sheet.tsx +725 -0
- package/src/products/variants-matrix.test.ts +154 -0
- package/src/products/variants-matrix.ts +244 -0
- package/src/products/variants-options-builder.tsx +571 -0
- package/src/products/variants-section.tsx +535 -0
- package/src/products/video-url.test.ts +71 -0
- package/src/products/video-url.ts +83 -0
- package/src/providers/auth-provider.tsx +70 -12
- package/src/providers/permission-provider.tsx +68 -28
- package/src/providers/sticky-header-provider.tsx +134 -0
- package/src/providers/store-provider.tsx +36 -3
- package/src/providers/tenant-provider.tsx +33 -0
- package/src/schemas/package-type.ts +95 -0
- package/src/schemas/stock-location.ts +126 -0
- package/src/tables/package-types.tsx +74 -0
- package/src/tables/stock-locations.tsx +124 -0
- package/src/vite/index.ts +40 -7
- 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
|
+
}
|