feat: enhance import/export functionality with detailed results dialog and improved data handling
This commit is contained in:
parent
fcbba6247d
commit
54d11f01b4
@ -24,6 +24,7 @@ export default function PartsPage() {
|
||||
<ImportDataButton
|
||||
onImport={(file) => api.parts.importData(file)}
|
||||
onSuccess={invalidateQuery}
|
||||
entityLabel="Parts"
|
||||
/>
|
||||
<ExportDataButton
|
||||
onExport={(filters) => api.parts.exportData(filters)}
|
||||
|
||||
@ -24,6 +24,7 @@ export default function ServicesPage() {
|
||||
<ImportDataButton
|
||||
onImport={(file) => api.services.importData(file)}
|
||||
onSuccess={invalidateQuery}
|
||||
entityLabel="Services"
|
||||
/>
|
||||
<ExportDataButton
|
||||
onExport={(filters) => api.services.exportData(filters)}
|
||||
|
||||
@ -32,6 +32,7 @@ export default function CustomersPage() {
|
||||
<ImportDataButton
|
||||
onImport={(file) => api.customers.importData(file)}
|
||||
onSuccess={invalidateQuery}
|
||||
entityLabel="Customers"
|
||||
/>
|
||||
<ExportDataButton
|
||||
onExport={(filters) => api.customers.exportData(filters)}
|
||||
|
||||
@ -39,7 +39,7 @@ export default function JobCardsPage() {
|
||||
const router = useRouter()
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<string>("check_in")
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||
|
||||
const filter = useFilterParams(jobCardFilterConfig)
|
||||
|
||||
|
||||
@ -35,6 +35,7 @@ export default function VehiclesPage() {
|
||||
<ImportDataButton
|
||||
onImport={(file) => api.vehicles.importData(file)}
|
||||
onSuccess={invalidateQuery}
|
||||
entityLabel="Vehicles"
|
||||
/>
|
||||
<ExportDataButton
|
||||
onExport={(filters) => api.vehicles.exportData(filters)}
|
||||
|
||||
@ -141,9 +141,20 @@ export function AppSidebar({ navGroups, logo, user, ...props }: AppSidebarProps)
|
||||
)
|
||||
}
|
||||
|
||||
function isPathActive(pathname: string, href: string): boolean {
|
||||
if (href === '/') return pathname === '/'
|
||||
return pathname === href || pathname.startsWith(href + '/')
|
||||
}
|
||||
|
||||
function isItemActive(pathname: string, item: { href: string; matchPath?: string; isActive?: boolean }): boolean {
|
||||
if (item.isActive !== undefined) return item.isActive
|
||||
if (item.matchPath) return isPathActive(pathname, item.matchPath)
|
||||
return isPathActive(pathname, item.href)
|
||||
}
|
||||
|
||||
function SimpleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) {
|
||||
const pathname = usePathname()
|
||||
const isActive = item.isActive ?? pathname === item.href
|
||||
const isActive = isItemActive(pathname, item)
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
@ -169,8 +180,8 @@ function SimpleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: bool
|
||||
|
||||
function CollapsibleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) {
|
||||
const pathname = usePathname()
|
||||
const isChildActive = item.items?.some((sub) => pathname === sub.href)
|
||||
const isActive = item.isActive ?? (pathname === item.href || isChildActive === true)
|
||||
const isChildActive = item.items?.some((sub) => isItemActive(pathname, sub))
|
||||
const isActive = item.isActive ?? (isItemActive(pathname, item) || isChildActive === true)
|
||||
|
||||
// Collapsed sidebar → flyout dropdown with sub-items
|
||||
if (isCollapsed) {
|
||||
@ -210,7 +221,7 @@ function CollapsibleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed:
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{item.items?.map((sub) => {
|
||||
const isSubActive = sub.isActive ?? pathname === sub.href
|
||||
const isSubActive = isItemActive(pathname, sub)
|
||||
return (
|
||||
<DropdownMenuItem key={sub.href} asChild>
|
||||
<Link
|
||||
@ -272,7 +283,7 @@ function CollapsibleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed:
|
||||
<CollapsibleContent className="overflow-hidden py-2 data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((sub) => {
|
||||
const isSubActive = sub.isActive ?? pathname === sub.href
|
||||
const isSubActive = isItemActive(pathname, sub)
|
||||
return (
|
||||
<SidebarMenuSubItem key={sub.href}>
|
||||
<SidebarMenuSubButton asChild isActive={isSubActive} className="dashboard-nav-sub-item my-0.5">
|
||||
|
||||
@ -68,14 +68,22 @@ export default function DashboardDetailsPageLayout({
|
||||
</Button>
|
||||
)}
|
||||
{(avatarSrc || avatarFallback) && (
|
||||
<a rel="preload" target="_blank" href={avatarSrc} >
|
||||
avatarSrc ? (
|
||||
<a target="_blank" rel="noopener noreferrer" href={avatarSrc}>
|
||||
<Avatar className="size-14">
|
||||
{avatarSrc && <AvatarImage src={avatarSrc} alt={title} />}
|
||||
<AvatarImage src={avatarSrc} alt={title} />
|
||||
<AvatarFallback>
|
||||
{avatarFallback ?? title.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</a>
|
||||
) : (
|
||||
<Avatar className="size-14">
|
||||
<AvatarFallback>
|
||||
{avatarFallback ?? title.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
)}
|
||||
{!avatarSrc && !avatarFallback && icon && (
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-muted text-muted-foreground">
|
||||
|
||||
@ -6,6 +6,12 @@ export type NavItem = {
|
||||
href: string
|
||||
icon?: ReactNode
|
||||
isActive?: boolean
|
||||
/**
|
||||
* Optional prefix used to decide active state when the link target
|
||||
* differs from the page's URL pattern (e.g. `/calendar/appointment/list`
|
||||
* is the link, but the item should stay active on `/calendar/appointment/{id}`).
|
||||
*/
|
||||
matchPath?: string
|
||||
badge?: string | number
|
||||
items?: NavSubItem[]
|
||||
}
|
||||
@ -15,6 +21,7 @@ export type NavSubItem = {
|
||||
href: string
|
||||
icon?: ReactNode
|
||||
isActive?: boolean
|
||||
matchPath?: string
|
||||
}
|
||||
|
||||
export type NavGroup = {
|
||||
|
||||
@ -76,7 +76,7 @@ export const navGroups: NavGroup[] = [
|
||||
icon: <CalendarIcon />,
|
||||
items: [
|
||||
// { title: "Work Schedule", href: "/calendar/work-schedule/list", icon: <Clock3Icon /> },
|
||||
{ title: "Appointments", href: "/calendar/appointment/list", icon: <CalendarCheck2Icon /> },
|
||||
{ title: "Appointments", href: "/calendar/appointment/list", matchPath: "/calendar/appointment", icon: <CalendarCheck2Icon /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -176,7 +176,7 @@ export const navGroups: NavGroup[] = [
|
||||
{ title: "Insurance Types", href: "/settings/insurance-types", icon: <ShieldIcon /> },
|
||||
{ title: "Tax & Rates", href: "/settings/tax-rates", icon: <ReceiptTextIcon /> },
|
||||
{ title: "Make & Models", href: "/settings/make-and-models", icon: <CarIcon /> },
|
||||
{ title: "Configurations", href: "/settings/configurations/preferences/sales", icon: <SettingsIcon /> },
|
||||
{ title: "Configurations", href: "/settings/configurations/preferences/sales", matchPath: "/settings/configurations", icon: <SettingsIcon /> },
|
||||
// { title: "Templates", href: "/settings/templates", icon: <ClipboardListIcon /> },
|
||||
// { title: "Integrations", href: "/settings/integrations/providers", icon: <PlugZapIcon /> },
|
||||
// { title: "Master", href: "/settings/master/body-type", icon: <ListIcon /> },
|
||||
|
||||
@ -52,6 +52,27 @@ function buildVehicleOption(item: any): VehicleOption {
|
||||
}
|
||||
}
|
||||
|
||||
function VehicleThumb({ src }: { src?: string }) {
|
||||
const [broken, setBroken] = useState(false)
|
||||
if (!src || broken) {
|
||||
return (
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground border border-border">
|
||||
<Car className="size-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Image
|
||||
height={60}
|
||||
width={60}
|
||||
src={src}
|
||||
alt=""
|
||||
onError={() => setBroken(true)}
|
||||
className="size-9 shrink-0 rounded-md object-cover border border-border"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function extractItems(response: unknown): any[] {
|
||||
if (Array.isArray(response)) return response
|
||||
const obj = response as any
|
||||
@ -168,20 +189,7 @@ export function RhfVehicleSelectField<
|
||||
filtered.map((opt) => (
|
||||
<ComboboxItem key={opt.value} value={opt}>
|
||||
<div className="flex items-center gap-3 py-0.5 w-full min-w-0">
|
||||
{/* Thumbnail */}
|
||||
{opt.image_url ? (
|
||||
<Image
|
||||
height={60}
|
||||
width={60}
|
||||
src={opt.image_url}
|
||||
alt=""
|
||||
className="size-9 shrink-0 rounded-md object-cover border border-border"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground border border-border">
|
||||
<Car className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
<VehicleThumb src={opt.image_url} />
|
||||
|
||||
{/* Identity */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
|
||||
@ -69,6 +69,9 @@ const mapLookupOption = (item: any) => ({
|
||||
|
||||
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label }
|
||||
|
||||
const toFormString = (value: unknown): string =>
|
||||
value == null ? "" : String(value)
|
||||
|
||||
function mapToFormValues(data: unknown): VehicleFormValues {
|
||||
const d = (data as any)?.data ?? data ?? {}
|
||||
|
||||
@ -79,16 +82,16 @@ function mapToFormValues(data: unknown): VehicleFormValues {
|
||||
vehicle_transmission_id: toRelation(d.vehicle_transmission_id, d.vehicle_transmission?.title),
|
||||
vehicle_color_id: toRelation(d.vehicle_color_id, d.vehicle_color?.title),
|
||||
customer_id: toRelation(d.customer_id, d.customer?.name),
|
||||
make: d.make || "",
|
||||
model: d.model || "",
|
||||
year: d.year || "",
|
||||
sub_model: d.sub_model || "",
|
||||
license_plate: d.license_plate || "",
|
||||
vin_number: d.vin_number || "",
|
||||
engine_size: d.engine_size || "",
|
||||
drivetrain: d.drivetrain || "",
|
||||
mileage: d.mileage || "",
|
||||
note: d.note || "",
|
||||
make: toFormString(d.make),
|
||||
model: toFormString(d.model),
|
||||
year: toFormString(d.year),
|
||||
sub_model: toFormString(d.sub_model),
|
||||
license_plate: toFormString(d.license_plate),
|
||||
vin_number: toFormString(d.vin_number),
|
||||
engine_size: toFormString(d.engine_size),
|
||||
drivetrain: toFormString(d.drivetrain),
|
||||
mileage: toFormString(d.mileage),
|
||||
note: toFormString(d.note),
|
||||
image: null,
|
||||
}
|
||||
}
|
||||
@ -121,7 +124,7 @@ function mapToPayload(values: VehicleFormValues) {
|
||||
export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormProps) {
|
||||
const api = useAuthApi()
|
||||
|
||||
const { form, isEditing } = useResourceForm<VehicleFormValues, any>({
|
||||
const { form, isEditing, data: resolvedData } = useResourceForm<VehicleFormValues, any>({
|
||||
schema: vehicleFormSchema,
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
@ -131,6 +134,9 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
const existingImageUrl: string | null =
|
||||
(resolvedData as any)?.data?.image_url ?? (resolvedData as any)?.image_url ?? null
|
||||
|
||||
const { mutate, error, isPending } = useFormMutation(form, {
|
||||
mutationFn: (values: VehicleFormValues) => {
|
||||
const payload = mapToPayload(values)
|
||||
@ -260,7 +266,11 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
|
||||
<RhfTextField name="mileage" label="Mileage" placeholder="e.g. 10000" />
|
||||
</div>
|
||||
|
||||
<RhfImageField name="image" label="Image" />
|
||||
<RhfImageField
|
||||
name="image"
|
||||
label="Image"
|
||||
initialPreviewUrl={existingImageUrl}
|
||||
/>
|
||||
|
||||
<RhfTextareaField name="note" label="Notes" rows={3} />
|
||||
|
||||
|
||||
@ -8,6 +8,18 @@ const nextConfig = {
|
||||
hostname: 'newgarage.yslootahtech.com',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'reparee.test',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'reparee.test',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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")
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
138
apps/dashboard/shared/components/import-results-dialog.tsx
Normal file
138
apps/dashboard/shared/components/import-results-dialog.tsx
Normal file
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@ export abstract class CrudClient<
|
||||
return this.delete<ByIdRoute>(this.byIdRoute as ByIdRoute, { params: { id } } as never) as never
|
||||
}
|
||||
|
||||
async importData(file: File): Promise<{ message?: string }> {
|
||||
async importData(file: File): Promise<ImportDataResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
const route = (this.importRoute ?? `${this.indexRoute}/import`) as string
|
||||
@ -78,6 +78,26 @@ export abstract class CrudClient<
|
||||
|
||||
export type BaseCrudItem = { id: number }
|
||||
|
||||
export type ImportFailureRow = {
|
||||
row: number
|
||||
errors: {
|
||||
field: string
|
||||
label: string
|
||||
value: unknown
|
||||
message: string
|
||||
valid_examples?: string[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export type ImportDataResponse = {
|
||||
message?: string
|
||||
data?: {
|
||||
imported_count: number
|
||||
failed_count: number
|
||||
failed_rows: ImportFailureRow[]
|
||||
}
|
||||
}
|
||||
|
||||
type ClientMethodReturn<C, TMethod extends PropertyKey> =
|
||||
C extends { [K in TMethod]: (...args: any[]) => infer TResult }
|
||||
? Awaited<TResult>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user