updates
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Car, Loader2 } from "lucide-react"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { VEHICLE_ROUTES } from "@garage/api"
|
||||
import { FieldShell } from "@/shared/components/form/field-shell"
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
} from "@/shared/components/ui/combobox"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import Image from "next/image"
|
||||
|
||||
// ── Vehicle option type (enriched for display) ──
|
||||
|
||||
type VehicleOption = {
|
||||
value: string
|
||||
label: string
|
||||
make?: string
|
||||
model?: string
|
||||
year?: string
|
||||
sub_model?: string
|
||||
license_plate?: string
|
||||
image_url?: string
|
||||
mileage?: string
|
||||
}
|
||||
|
||||
function buildVehicleOption(item: any): VehicleOption {
|
||||
const label =
|
||||
[item.year, item.make, item.model].filter(Boolean).join(" ") ||
|
||||
`Vehicle #${item.id}`
|
||||
return {
|
||||
value: String(item.id),
|
||||
label,
|
||||
make: item.make,
|
||||
model: item.model,
|
||||
year: item.year,
|
||||
sub_model: item.sub_model,
|
||||
license_plate: item.license_plate,
|
||||
image_url: item.image_url,
|
||||
mileage: item.mileage,
|
||||
}
|
||||
}
|
||||
|
||||
function extractItems(response: unknown): any[] {
|
||||
if (Array.isArray(response)) return response
|
||||
const obj = response as any
|
||||
if (Array.isArray(obj?.data?.data)) return obj.data.data
|
||||
if (Array.isArray(obj?.data)) return obj.data
|
||||
return []
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
|
||||
export type RhfVehicleSelectFieldProps<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
> = {
|
||||
name: TName
|
||||
label?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function RhfVehicleSelectField<
|
||||
TValues extends FieldValues,
|
||||
TName extends FieldPath<TValues>,
|
||||
>({
|
||||
name,
|
||||
label = "Vehicle",
|
||||
description,
|
||||
required,
|
||||
disabled,
|
||||
placeholder = "Search by make, model, year, or plate...",
|
||||
}: RhfVehicleSelectFieldProps<TValues, TName>) {
|
||||
const api = useAuthApi()
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
|
||||
const { control } = useFormContext<TValues>()
|
||||
const {
|
||||
field,
|
||||
fieldState: { error },
|
||||
} = useController({ name, control, disabled })
|
||||
|
||||
const { data: options = [], isLoading } = useQuery<VehicleOption[]>({
|
||||
queryKey: [VEHICLE_ROUTES.INDEX, "vehicle-select"],
|
||||
queryFn: async () => {
|
||||
const res = await api.vehicles.list()
|
||||
return extractItems(res).map(buildVehicleOption)
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const filtered = inputValue
|
||||
? options.filter((v) =>
|
||||
[v.year, v.make, v.model, v.sub_model, v.license_plate]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase()),
|
||||
)
|
||||
: options
|
||||
|
||||
return (
|
||||
<FieldShell
|
||||
label={label}
|
||||
error={error?.message}
|
||||
description={description}
|
||||
required={required}
|
||||
>
|
||||
<div ref={anchorRef}>
|
||||
<Combobox
|
||||
value={field.value}
|
||||
onValueChange={(val: VehicleOption | VehicleOption[] | null) => {
|
||||
const single = Array.isArray(val) ? val[0] ?? null : val
|
||||
// Store only { value, label } to stay compatible with relationFieldSchema
|
||||
field.onChange(
|
||||
single ? { value: single.value, label: single.label } : null,
|
||||
)
|
||||
}}
|
||||
disabled={field.disabled}
|
||||
onInputValueChange={(val: string, { reason }: { reason: string }) => {
|
||||
if (reason === "input-change") setInputValue(val)
|
||||
}}
|
||||
// Compare by id string so the selected item highlights correctly
|
||||
// even when the stored form value has fewer fields than VehicleOption
|
||||
isItemEqualToValue={(item: VehicleOption, val: any) =>
|
||||
item?.value === val?.value
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder={placeholder}
|
||||
showClear={!!field.value}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={!!error || undefined}
|
||||
/>
|
||||
<ComboboxContent anchor={anchorRef}>
|
||||
<ComboboxList>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Identity */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium leading-none">
|
||||
{[opt.year, opt.make, opt.model]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{opt.sub_model && (
|
||||
<span className="ml-1 font-normal text-muted-foreground">
|
||||
{opt.sub_model}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{opt.license_plate && (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{opt.license_plate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
))}
|
||||
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<ComboboxEmpty>No vehicles found</ComboboxEmpty>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
</FieldShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { VehicleListResponse } from "@garage/api"
|
||||
|
||||
export const getVehicleLabel = (vehicle: VehicleListResponse) => {
|
||||
return `${vehicle.make} ${vehicle.model} ${vehicle.sub_model} (${vehicle.year} - ${vehicle.license_plate})`
|
||||
}
|
||||
Reference in New Issue
Block a user