feat: enhance import/export functionality with detailed results dialog and improved data handling

This commit is contained in:
2026-05-15 11:34:23 +04:00
parent fcbba6247d
commit 54d11f01b4
18 changed files with 312 additions and 59 deletions
@@ -2,7 +2,7 @@
import { useState } from "react"
import { Button } from "@/shared/components/ui/button"
import { Download, Loader2 } from "lucide-react"
import { Upload, Loader2 } from "lucide-react"
import { toast } from "sonner"
type ExportDataButtonProps = {
@@ -47,7 +47,7 @@ export function ExportDataButton({
disabled={isPending}
onClick={handleExport}
>
{isPending ? <Loader2 className="animate-spin" /> : <Download />}
{isPending ? <Loader2 className="animate-spin" /> : <Upload />}
{label}
</Button>
)
@@ -7,6 +7,7 @@ import { cn } from "@/shared/lib/utils"
export type ImageInputFieldProps = BaseFieldControlProps<File | null> & {
accept?: string
initialPreviewUrl?: string | null
}
export function ImageInputField({
@@ -17,21 +18,24 @@ export function ImageInputField({
disabled,
invalid,
accept = "image/*",
initialPreviewUrl,
}: ImageInputFieldProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [preview, setPreview] = useState<string | null>(null)
const [filePreview, setFilePreview] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
useEffect(() => {
if (!value) {
setPreview(null)
setFilePreview(null)
return
}
const url = URL.createObjectURL(value)
setPreview(url)
setFilePreview(url)
return () => URL.revokeObjectURL(url)
}, [value])
const preview = filePreview ?? initialPreviewUrl ?? null
const handleFile = useCallback(
(file: File | null) => {
if (file && !file.type.startsWith("image/")) return
@@ -110,7 +114,7 @@ export function ImageInputField({
alt="Preview"
className="max-h-30 max-w-full rounded-md object-contain"
/>
{!disabled && (
{!disabled && filePreview && (
<button
type="button"
onClick={handleClear}
@@ -2,14 +2,17 @@
import React, { useRef, useState } from "react"
import { Button } from "@/shared/components/ui/button"
import { Upload, Loader2 } from "lucide-react"
import { Download, Loader2 } from "lucide-react"
import { toast } from "sonner"
import { ImportResultsDialog } from "@/shared/components/import-results-dialog"
import type { ImportDataResponse } from "@garage/api"
type ImportDataButtonProps = {
onImport: (file: File) => Promise<{ message?: string }>
onImport: (file: File) => Promise<ImportDataResponse>
onSuccess?: () => void
accept?: string
label?: string
entityLabel?: string
}
export function ImportDataButton({
@@ -17,9 +20,12 @@ export function ImportDataButton({
onSuccess,
accept = ".xlsx,.xls,.csv",
label = "Import",
entityLabel,
}: ImportDataButtonProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [isPending, setIsPending] = useState(false)
const [result, setResult] = useState<ImportDataResponse["data"] | null>(null)
const [resultOpen, setResultOpen] = useState(false)
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
@@ -27,9 +33,16 @@ export function ImportDataButton({
setIsPending(true)
try {
const result = await onImport(file)
toast.success(result.message ?? "Data imported successfully")
onSuccess?.()
const response = await onImport(file)
const data = response.data ?? { imported_count: 0, failed_count: 0, failed_rows: [] }
setResult(data)
setResultOpen(true)
if (data.failed_count === 0) {
onSuccess?.()
} else if (data.imported_count > 0) {
onSuccess?.()
}
} catch (err: any) {
toast.error(err?.message ?? "Failed to import data")
} finally {
@@ -53,9 +66,19 @@ export function ImportDataButton({
disabled={isPending}
onClick={() => inputRef.current?.click()}
>
{isPending ? <Loader2 className="animate-spin" /> : <Upload />}
{isPending ? <Loader2 className="animate-spin" /> : <Download />}
{label}
</Button>
{result && (
<ImportResultsDialog
open={resultOpen}
onOpenChange={setResultOpen}
importedCount={result.imported_count}
failedCount={result.failed_count}
failedRows={result.failed_rows}
entityLabel={entityLabel}
/>
)}
</>
)
}
@@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { CheckCircle2, XCircle } from "lucide-react"
import type { ImportFailureRow } from "@garage/api"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Button } from "@/shared/components/ui/button"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
export type { ImportFailureRow }
export type ImportResultsDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
importedCount: number
failedCount: number
failedRows: ImportFailureRow[]
entityLabel?: string
}
function formatValue(value: unknown): string {
if (value === null || value === undefined || value === "") return "—"
if (typeof value === "string") return value
if (typeof value === "number" || typeof value === "boolean") return String(value)
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
export function ImportResultsDialog({
open,
onOpenChange,
importedCount,
failedCount,
failedRows,
entityLabel = "Records",
}: ImportResultsDialogProps) {
const hasFailures = failedCount > 0
const flatFailures = failedRows.flatMap((r) =>
r.errors.map((e) => ({ row: r.row, ...e })),
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(96vw,64rem)] sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Import results</DialogTitle>
<DialogDescription>
{hasFailures
? "Some rows could not be imported. Fix the issues below and re-upload only the failing rows."
: `All ${entityLabel.toLowerCase()} imported successfully.`}
</DialogDescription>
</DialogHeader>
<div className="flex gap-3">
<div className="flex items-center gap-2 rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
<CheckCircle2 className="size-4" />
<span>
<strong>{importedCount}</strong> imported
</span>
</div>
{hasFailures && (
<div className="flex items-center gap-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
<XCircle className="size-4" />
<span>
<strong>{failedCount}</strong> failed
</span>
</div>
)}
</div>
{hasFailures && (
<ScrollArea className="max-h-96 rounded-md border">
<Table className="table-fixed w-full">
<TableHeader>
<TableRow>
<TableHead className="w-16">Row</TableHead>
<TableHead className="w-40">Field</TableHead>
<TableHead className="w-56">Value</TableHead>
<TableHead>Reason</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{flatFailures.map((f, idx) => (
<TableRow key={`${f.row}-${f.field}-${idx}`}>
<TableCell className="font-mono text-xs">{f.row}</TableCell>
<TableCell className="font-medium">{f.label}</TableCell>
<TableCell className="text-muted-foreground break-all whitespace-pre-wrap">
{formatValue(f.value)}
</TableCell>
<TableCell className="break-words">
<div>{f.message}</div>
{f.valid_examples && f.valid_examples.length > 0 && (
<details className="mt-1 text-xs text-muted-foreground">
<summary className="cursor-pointer">
Try one of: {f.valid_examples.slice(0, 3).join(", ")}
{f.valid_examples.length > 3 ? "…" : ""}
</summary>
<div className="mt-1">
{f.valid_examples.join(", ")}
</div>
</details>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -16,11 +16,12 @@ type UseResourceFormOptions<TFormValues extends FieldValues, TApiData = unknown>
queryKey?: QueryKey
}
type UseResourceFormReturn<TFormValues extends FieldValues> = {
type UseResourceFormReturn<TFormValues extends FieldValues, TApiData = unknown> = {
form: UseFormReturn<TFormValues>
isEditing: boolean
isInitializing: boolean
invalidate: () => void
data: TApiData | undefined
}
export function useResourceForm<TFormValues extends FieldValues, TApiData = unknown>({
@@ -31,7 +32,7 @@ export function useResourceForm<TFormValues extends FieldValues, TApiData = unkn
mapToFormValues,
initialData,
queryKey,
}: UseResourceFormOptions<TFormValues, TApiData>): UseResourceFormReturn<TFormValues> {
}: UseResourceFormOptions<TFormValues, TApiData>): UseResourceFormReturn<TFormValues, TApiData> {
const isEditing = !!resourceId
const queryClient = useQueryClient()
const resolvedQueryKey = queryKey ?? ["resource", resourceId]
@@ -44,7 +45,8 @@ export function useResourceForm<TFormValues extends FieldValues, TApiData = unkn
refetchOnMount: "always",
})
const resolvedData = queriedData ?? (isEditing ? initialData : undefined)
const resolvedData: TApiData | undefined =
queriedData ?? (isEditing ? (initialData ?? undefined) : undefined)
const form = useForm<TFormValues>({
resolver: zodResolver(schema) as any,
@@ -70,5 +72,11 @@ export function useResourceForm<TFormValues extends FieldValues, TApiData = unkn
queryClient.invalidateQueries({ queryKey: resolvedQueryKey })
}
return { form, isEditing, isInitializing: isEditing && !!initialize && isQueryLoading, invalidate }
return {
form,
isEditing,
isInitializing: isEditing && !!initialize && isQueryLoading,
invalidate,
data: resolvedData,
}
}