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

This commit is contained in:
humam kerdiah 2026-05-15 11:34:23 +04:00
parent fcbba6247d
commit 54d11f01b4
18 changed files with 312 additions and 59 deletions

View File

@ -24,6 +24,7 @@ export default function PartsPage() {
<ImportDataButton <ImportDataButton
onImport={(file) => api.parts.importData(file)} onImport={(file) => api.parts.importData(file)}
onSuccess={invalidateQuery} onSuccess={invalidateQuery}
entityLabel="Parts"
/> />
<ExportDataButton <ExportDataButton
onExport={(filters) => api.parts.exportData(filters)} onExport={(filters) => api.parts.exportData(filters)}

View File

@ -24,6 +24,7 @@ export default function ServicesPage() {
<ImportDataButton <ImportDataButton
onImport={(file) => api.services.importData(file)} onImport={(file) => api.services.importData(file)}
onSuccess={invalidateQuery} onSuccess={invalidateQuery}
entityLabel="Services"
/> />
<ExportDataButton <ExportDataButton
onExport={(filters) => api.services.exportData(filters)} onExport={(filters) => api.services.exportData(filters)}

View File

@ -32,6 +32,7 @@ export default function CustomersPage() {
<ImportDataButton <ImportDataButton
onImport={(file) => api.customers.importData(file)} onImport={(file) => api.customers.importData(file)}
onSuccess={invalidateQuery} onSuccess={invalidateQuery}
entityLabel="Customers"
/> />
<ExportDataButton <ExportDataButton
onExport={(filters) => api.customers.exportData(filters)} onExport={(filters) => api.customers.exportData(filters)}

View File

@ -39,7 +39,7 @@ export default function JobCardsPage() {
const router = useRouter() const router = useRouter()
const [searchInput, setSearchInput] = useState("") const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState<string>("check_in") const [statusFilter, setStatusFilter] = useState<string>("all")
const filter = useFilterParams(jobCardFilterConfig) const filter = useFilterParams(jobCardFilterConfig)

View File

@ -35,6 +35,7 @@ export default function VehiclesPage() {
<ImportDataButton <ImportDataButton
onImport={(file) => api.vehicles.importData(file)} onImport={(file) => api.vehicles.importData(file)}
onSuccess={invalidateQuery} onSuccess={invalidateQuery}
entityLabel="Vehicles"
/> />
<ExportDataButton <ExportDataButton
onExport={(filters) => api.vehicles.exportData(filters)} onExport={(filters) => api.vehicles.exportData(filters)}

View File

@ -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 }) { function SimpleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) {
const pathname = usePathname() const pathname = usePathname()
const isActive = item.isActive ?? pathname === item.href const isActive = isItemActive(pathname, item)
return ( return (
<SidebarMenuItem> <SidebarMenuItem>
@ -169,8 +180,8 @@ function SimpleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: bool
function CollapsibleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) { function CollapsibleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) {
const pathname = usePathname() const pathname = usePathname()
const isChildActive = item.items?.some((sub) => pathname === sub.href) const isChildActive = item.items?.some((sub) => isItemActive(pathname, sub))
const isActive = item.isActive ?? (pathname === item.href || isChildActive === true) const isActive = item.isActive ?? (isItemActive(pathname, item) || isChildActive === true)
// Collapsed sidebar → flyout dropdown with sub-items // Collapsed sidebar → flyout dropdown with sub-items
if (isCollapsed) { if (isCollapsed) {
@ -210,7 +221,7 @@ function CollapsibleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed:
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{item.items?.map((sub) => { {item.items?.map((sub) => {
const isSubActive = sub.isActive ?? pathname === sub.href const isSubActive = isItemActive(pathname, sub)
return ( return (
<DropdownMenuItem key={sub.href} asChild> <DropdownMenuItem key={sub.href} asChild>
<Link <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"> <CollapsibleContent className="overflow-hidden py-2 data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
<SidebarMenuSub> <SidebarMenuSub>
{item.items?.map((sub) => { {item.items?.map((sub) => {
const isSubActive = sub.isActive ?? pathname === sub.href const isSubActive = isItemActive(pathname, sub)
return ( return (
<SidebarMenuSubItem key={sub.href}> <SidebarMenuSubItem key={sub.href}>
<SidebarMenuSubButton asChild isActive={isSubActive} className="dashboard-nav-sub-item my-0.5"> <SidebarMenuSubButton asChild isActive={isSubActive} className="dashboard-nav-sub-item my-0.5">

View File

@ -68,14 +68,22 @@ export default function DashboardDetailsPageLayout({
</Button> </Button>
)} )}
{(avatarSrc || avatarFallback) && ( {(avatarSrc || avatarFallback) && (
<a rel="preload" target="_blank" href={avatarSrc} > avatarSrc ? (
<a target="_blank" rel="noopener noreferrer" href={avatarSrc}>
<Avatar className="size-14"> <Avatar className="size-14">
{avatarSrc && <AvatarImage src={avatarSrc} alt={title} />} <AvatarImage src={avatarSrc} alt={title} />
<AvatarFallback> <AvatarFallback>
{avatarFallback ?? title.charAt(0).toUpperCase()} {avatarFallback ?? title.charAt(0).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
</a> </a>
) : (
<Avatar className="size-14">
<AvatarFallback>
{avatarFallback ?? title.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
)
)} )}
{!avatarSrc && !avatarFallback && icon && ( {!avatarSrc && !avatarFallback && icon && (
<div className="flex items-center justify-center size-10 rounded-full bg-muted text-muted-foreground"> <div className="flex items-center justify-center size-10 rounded-full bg-muted text-muted-foreground">

View File

@ -6,6 +6,12 @@ export type NavItem = {
href: string href: string
icon?: ReactNode icon?: ReactNode
isActive?: boolean 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 badge?: string | number
items?: NavSubItem[] items?: NavSubItem[]
} }
@ -15,6 +21,7 @@ export type NavSubItem = {
href: string href: string
icon?: ReactNode icon?: ReactNode
isActive?: boolean isActive?: boolean
matchPath?: string
} }
export type NavGroup = { export type NavGroup = {

View File

@ -76,7 +76,7 @@ export const navGroups: NavGroup[] = [
icon: <CalendarIcon />, icon: <CalendarIcon />,
items: [ items: [
// { title: "Work Schedule", href: "/calendar/work-schedule/list", icon: <Clock3Icon /> }, // { 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: "Insurance Types", href: "/settings/insurance-types", icon: <ShieldIcon /> },
{ title: "Tax & Rates", href: "/settings/tax-rates", icon: <ReceiptTextIcon /> }, { title: "Tax & Rates", href: "/settings/tax-rates", icon: <ReceiptTextIcon /> },
{ title: "Make & Models", href: "/settings/make-and-models", icon: <CarIcon /> }, { 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: "Templates", href: "/settings/templates", icon: <ClipboardListIcon /> },
// { title: "Integrations", href: "/settings/integrations/providers", icon: <PlugZapIcon /> }, // { title: "Integrations", href: "/settings/integrations/providers", icon: <PlugZapIcon /> },
// { title: "Master", href: "/settings/master/body-type", icon: <ListIcon /> }, // { title: "Master", href: "/settings/master/body-type", icon: <ListIcon /> },

View File

@ -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[] { function extractItems(response: unknown): any[] {
if (Array.isArray(response)) return response if (Array.isArray(response)) return response
const obj = response as any const obj = response as any
@ -168,20 +189,7 @@ export function RhfVehicleSelectField<
filtered.map((opt) => ( filtered.map((opt) => (
<ComboboxItem key={opt.value} value={opt}> <ComboboxItem key={opt.value} value={opt}>
<div className="flex items-center gap-3 py-0.5 w-full min-w-0"> <div className="flex items-center gap-3 py-0.5 w-full min-w-0">
{/* Thumbnail */} <VehicleThumb src={opt.image_url} />
{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>
)}
{/* Identity */} {/* Identity */}
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> <div className="flex min-w-0 flex-1 flex-col gap-0.5">

View File

@ -69,6 +69,9 @@ const mapLookupOption = (item: any) => ({
const STORE_OBJECT = { getOptionValue: (o: any) => o, getOptionLabel: (o: any) => o.label } 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 { function mapToFormValues(data: unknown): VehicleFormValues {
const d = (data as any)?.data ?? data ?? {} 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_transmission_id: toRelation(d.vehicle_transmission_id, d.vehicle_transmission?.title),
vehicle_color_id: toRelation(d.vehicle_color_id, d.vehicle_color?.title), vehicle_color_id: toRelation(d.vehicle_color_id, d.vehicle_color?.title),
customer_id: toRelation(d.customer_id, d.customer?.name), customer_id: toRelation(d.customer_id, d.customer?.name),
make: d.make || "", make: toFormString(d.make),
model: d.model || "", model: toFormString(d.model),
year: d.year || "", year: toFormString(d.year),
sub_model: d.sub_model || "", sub_model: toFormString(d.sub_model),
license_plate: d.license_plate || "", license_plate: toFormString(d.license_plate),
vin_number: d.vin_number || "", vin_number: toFormString(d.vin_number),
engine_size: d.engine_size || "", engine_size: toFormString(d.engine_size),
drivetrain: d.drivetrain || "", drivetrain: toFormString(d.drivetrain),
mileage: d.mileage || "", mileage: toFormString(d.mileage),
note: d.note || "", note: toFormString(d.note),
image: null, image: null,
} }
} }
@ -121,7 +124,7 @@ function mapToPayload(values: VehicleFormValues) {
export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormProps) { export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormProps) {
const api = useAuthApi() const api = useAuthApi()
const { form, isEditing } = useResourceForm<VehicleFormValues, any>({ const { form, isEditing, data: resolvedData } = useResourceForm<VehicleFormValues, any>({
schema: vehicleFormSchema, schema: vehicleFormSchema,
defaultValues: DEFAULT_VALUES, defaultValues: DEFAULT_VALUES,
resourceId, resourceId,
@ -131,6 +134,9 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
mapToFormValues, mapToFormValues,
}) })
const existingImageUrl: string | null =
(resolvedData as any)?.data?.image_url ?? (resolvedData as any)?.image_url ?? null
const { mutate, error, isPending } = useFormMutation(form, { const { mutate, error, isPending } = useFormMutation(form, {
mutationFn: (values: VehicleFormValues) => { mutationFn: (values: VehicleFormValues) => {
const payload = mapToPayload(values) const payload = mapToPayload(values)
@ -260,7 +266,11 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
<RhfTextField name="mileage" label="Mileage" placeholder="e.g. 10000" /> <RhfTextField name="mileage" label="Mileage" placeholder="e.g. 10000" />
</div> </div>
<RhfImageField name="image" label="Image" /> <RhfImageField
name="image"
label="Image"
initialPreviewUrl={existingImageUrl}
/>
<RhfTextareaField name="note" label="Notes" rows={3} /> <RhfTextareaField name="note" label="Notes" rows={3} />

View File

@ -8,6 +8,18 @@ const nextConfig = {
hostname: 'newgarage.yslootahtech.com', hostname: 'newgarage.yslootahtech.com',
port: '', port: '',
pathname: '/**', pathname: '/**',
},
{
protocol: 'http',
hostname: 'reparee.test',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'reparee.test',
port: '',
pathname: '/**',
} }
] ]
} }

View File

@ -2,7 +2,7 @@
import { useState } from "react" import { useState } from "react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Download, Loader2 } from "lucide-react" import { Upload, Loader2 } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
type ExportDataButtonProps = { type ExportDataButtonProps = {
@ -47,7 +47,7 @@ export function ExportDataButton({
disabled={isPending} disabled={isPending}
onClick={handleExport} onClick={handleExport}
> >
{isPending ? <Loader2 className="animate-spin" /> : <Download />} {isPending ? <Loader2 className="animate-spin" /> : <Upload />}
{label} {label}
</Button> </Button>
) )

View File

@ -7,6 +7,7 @@ import { cn } from "@/shared/lib/utils"
export type ImageInputFieldProps = BaseFieldControlProps<File | null> & { export type ImageInputFieldProps = BaseFieldControlProps<File | null> & {
accept?: string accept?: string
initialPreviewUrl?: string | null
} }
export function ImageInputField({ export function ImageInputField({
@ -17,21 +18,24 @@ export function ImageInputField({
disabled, disabled,
invalid, invalid,
accept = "image/*", accept = "image/*",
initialPreviewUrl,
}: ImageInputFieldProps) { }: ImageInputFieldProps) {
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const [preview, setPreview] = useState<string | null>(null) const [filePreview, setFilePreview] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
useEffect(() => { useEffect(() => {
if (!value) { if (!value) {
setPreview(null) setFilePreview(null)
return return
} }
const url = URL.createObjectURL(value) const url = URL.createObjectURL(value)
setPreview(url) setFilePreview(url)
return () => URL.revokeObjectURL(url) return () => URL.revokeObjectURL(url)
}, [value]) }, [value])
const preview = filePreview ?? initialPreviewUrl ?? null
const handleFile = useCallback( const handleFile = useCallback(
(file: File | null) => { (file: File | null) => {
if (file && !file.type.startsWith("image/")) return if (file && !file.type.startsWith("image/")) return
@ -110,7 +114,7 @@ export function ImageInputField({
alt="Preview" alt="Preview"
className="max-h-30 max-w-full rounded-md object-contain" className="max-h-30 max-w-full rounded-md object-contain"
/> />
{!disabled && ( {!disabled && filePreview && (
<button <button
type="button" type="button"
onClick={handleClear} onClick={handleClear}

View File

@ -2,14 +2,17 @@
import React, { useRef, useState } from "react" import React, { useRef, useState } from "react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Upload, Loader2 } from "lucide-react" import { Download, Loader2 } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { ImportResultsDialog } from "@/shared/components/import-results-dialog"
import type { ImportDataResponse } from "@garage/api"
type ImportDataButtonProps = { type ImportDataButtonProps = {
onImport: (file: File) => Promise<{ message?: string }> onImport: (file: File) => Promise<ImportDataResponse>
onSuccess?: () => void onSuccess?: () => void
accept?: string accept?: string
label?: string label?: string
entityLabel?: string
} }
export function ImportDataButton({ export function ImportDataButton({
@ -17,9 +20,12 @@ export function ImportDataButton({
onSuccess, onSuccess,
accept = ".xlsx,.xls,.csv", accept = ".xlsx,.xls,.csv",
label = "Import", label = "Import",
entityLabel,
}: ImportDataButtonProps) { }: ImportDataButtonProps) {
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const [isPending, setIsPending] = useState(false) 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 handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
@ -27,9 +33,16 @@ export function ImportDataButton({
setIsPending(true) setIsPending(true)
try { try {
const result = await onImport(file) const response = await onImport(file)
toast.success(result.message ?? "Data imported successfully") const data = response.data ?? { imported_count: 0, failed_count: 0, failed_rows: [] }
setResult(data)
setResultOpen(true)
if (data.failed_count === 0) {
onSuccess?.() onSuccess?.()
} else if (data.imported_count > 0) {
onSuccess?.()
}
} catch (err: any) { } catch (err: any) {
toast.error(err?.message ?? "Failed to import data") toast.error(err?.message ?? "Failed to import data")
} finally { } finally {
@ -53,9 +66,19 @@ export function ImportDataButton({
disabled={isPending} disabled={isPending}
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
> >
{isPending ? <Loader2 className="animate-spin" /> : <Upload />} {isPending ? <Loader2 className="animate-spin" /> : <Download />}
{label} {label}
</Button> </Button>
{result && (
<ImportResultsDialog
open={resultOpen}
onOpenChange={setResultOpen}
importedCount={result.imported_count}
failedCount={result.failed_count}
failedRows={result.failed_rows}
entityLabel={entityLabel}
/>
)}
</> </>
) )
} }

View 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>
)
}

View File

@ -16,11 +16,12 @@ type UseResourceFormOptions<TFormValues extends FieldValues, TApiData = unknown>
queryKey?: QueryKey queryKey?: QueryKey
} }
type UseResourceFormReturn<TFormValues extends FieldValues> = { type UseResourceFormReturn<TFormValues extends FieldValues, TApiData = unknown> = {
form: UseFormReturn<TFormValues> form: UseFormReturn<TFormValues>
isEditing: boolean isEditing: boolean
isInitializing: boolean isInitializing: boolean
invalidate: () => void invalidate: () => void
data: TApiData | undefined
} }
export function useResourceForm<TFormValues extends FieldValues, TApiData = unknown>({ export function useResourceForm<TFormValues extends FieldValues, TApiData = unknown>({
@ -31,7 +32,7 @@ export function useResourceForm<TFormValues extends FieldValues, TApiData = unkn
mapToFormValues, mapToFormValues,
initialData, initialData,
queryKey, queryKey,
}: UseResourceFormOptions<TFormValues, TApiData>): UseResourceFormReturn<TFormValues> { }: UseResourceFormOptions<TFormValues, TApiData>): UseResourceFormReturn<TFormValues, TApiData> {
const isEditing = !!resourceId const isEditing = !!resourceId
const queryClient = useQueryClient() const queryClient = useQueryClient()
const resolvedQueryKey = queryKey ?? ["resource", resourceId] const resolvedQueryKey = queryKey ?? ["resource", resourceId]
@ -44,7 +45,8 @@ export function useResourceForm<TFormValues extends FieldValues, TApiData = unkn
refetchOnMount: "always", refetchOnMount: "always",
}) })
const resolvedData = queriedData ?? (isEditing ? initialData : undefined) const resolvedData: TApiData | undefined =
queriedData ?? (isEditing ? (initialData ?? undefined) : undefined)
const form = useForm<TFormValues>({ const form = useForm<TFormValues>({
resolver: zodResolver(schema) as any, resolver: zodResolver(schema) as any,
@ -70,5 +72,11 @@ export function useResourceForm<TFormValues extends FieldValues, TApiData = unkn
queryClient.invalidateQueries({ queryKey: resolvedQueryKey }) queryClient.invalidateQueries({ queryKey: resolvedQueryKey })
} }
return { form, isEditing, isInitializing: isEditing && !!initialize && isQueryLoading, invalidate } return {
form,
isEditing,
isInitializing: isEditing && !!initialize && isQueryLoading,
invalidate,
data: resolvedData,
}
} }

View File

@ -60,7 +60,7 @@ export abstract class CrudClient<
return this.delete<ByIdRoute>(this.byIdRoute as ByIdRoute, { params: { id } } as never) as never 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() const formData = new FormData()
formData.append("file", file) formData.append("file", file)
const route = (this.importRoute ?? `${this.indexRoute}/import`) as string const route = (this.importRoute ?? `${this.indexRoute}/import`) as string
@ -78,6 +78,26 @@ export abstract class CrudClient<
export type BaseCrudItem = { id: number } 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> = type ClientMethodReturn<C, TMethod extends PropertyKey> =
C extends { [K in TMethod]: (...args: any[]) => infer TResult } C extends { [K in TMethod]: (...args: any[]) => infer TResult }
? Awaited<TResult> ? Awaited<TResult>