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
|
<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)}
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -68,14 +68,22 @@ export default function DashboardDetailsPageLayout({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(avatarSrc || avatarFallback) && (
|
{(avatarSrc || avatarFallback) && (
|
||||||
<a rel="preload" target="_blank" href={avatarSrc} >
|
avatarSrc ? (
|
||||||
<Avatar className="size-14">
|
<a target="_blank" rel="noopener noreferrer" href={avatarSrc}>
|
||||||
{avatarSrc && <AvatarImage src={avatarSrc} alt={title} />}
|
<Avatar className="size-14">
|
||||||
<AvatarFallback>
|
<AvatarImage src={avatarSrc} alt={title} />
|
||||||
{avatarFallback ?? title.charAt(0).toUpperCase()}
|
<AvatarFallback>
|
||||||
</AvatarFallback>
|
{avatarFallback ?? title.charAt(0).toUpperCase()}
|
||||||
</Avatar>
|
</AvatarFallback>
|
||||||
</a>
|
</Avatar>
|
||||||
|
</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">
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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 /> },
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|
||||||
|
|||||||
@ -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: '/**',
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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: [] }
|
||||||
onSuccess?.()
|
setResult(data)
|
||||||
|
setResultOpen(true)
|
||||||
|
|
||||||
|
if (data.failed_count === 0) {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user