garage-erp/apps/dashboard/modules/vendors/rhf-vendor-select-field.tsx
2026-04-23 14:38:41 +03:00

238 lines
9.4 KiB
TypeScript

"use client"
import { useRef, useState } from "react"
import { useFormContext, useController, type FieldValues, type FieldPath } from "react-hook-form"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Building2, Loader2, PlusIcon } from "lucide-react"
import { useAuthApi } from "@/shared/useApi"
import { VENDOR_ROUTES } from "@garage/api"
import { Field, FieldLabel, FieldError, FieldDescription } from "@/shared/components/ui/field"
import { Button } from "@/shared/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
} from "@/shared/components/ui/combobox"
import { VendorForm } from "./vendor-form"
// ── Vendor option type ──
type VendorOption = {
value: string
label: string
first_name?: string
last_name?: string
company_name?: string
email?: string
phone?: string
}
function buildVendorOption(item: any): VendorOption {
const name = [item.first_name, item.last_name].filter(Boolean).join(" ")
const label = item.company_name || name || `Vendor #${item.id}`
return {
value: String(item.id),
label,
first_name: item.first_name,
last_name: item.last_name,
company_name: item.company_name,
email: item.email,
phone: item.phone,
}
}
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 []
}
function getInitials(opt: VendorOption): string {
if (opt.company_name) return opt.company_name[0].toUpperCase()
if (opt.first_name || opt.last_name) {
return [opt.first_name?.[0], opt.last_name?.[0]].filter(Boolean).join("").toUpperCase()
}
return "V"
}
// ── Props ──
export type RhfVendorSelectFieldProps<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
> = {
name: TName
label?: string
description?: string
required?: boolean
disabled?: boolean
placeholder?: string
}
// ── Component ──
export function RhfVendorSelectField<
TValues extends FieldValues,
TName extends FieldPath<TValues>,
>({
name,
label = "Vendor",
description,
required,
disabled,
placeholder = "Search by company name, name, or phone...",
}: RhfVendorSelectFieldProps<TValues, TName>) {
const api = useAuthApi()
const anchorRef = useRef<HTMLDivElement>(null)
const [inputValue, setInputValue] = useState("")
const [isCreateOpen, setIsCreateOpen] = useState(false)
const queryClient = useQueryClient()
const { control } = useFormContext<TValues>()
const {
field,
fieldState: { error },
} = useController({ name, control, disabled })
const { data: options = [], isLoading } = useQuery<VendorOption[]>({
queryKey: [VENDOR_ROUTES.INDEX, "vendor-select"],
queryFn: async () => {
const res = await api.vendors.list()
const items = extractItems(res)
return items.map(buildVendorOption)
},
staleTime: 5 * 60 * 1000,
})
const filtered = inputValue
? options.filter((v) =>
[v.company_name, v.first_name, v.last_name, v.email, v.phone]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(inputValue.toLowerCase()),
)
: options
const handleCreateSuccess = (data?: any) => {
const item = data?.data ?? data
if (item?.id) {
field.onChange(buildVendorOption(item))
}
queryClient.invalidateQueries({ queryKey: [VENDOR_ROUTES.INDEX, "vendor-select"] })
setIsCreateOpen(false)
}
return (
<Field data-invalid={!!error?.message || undefined}>
{label && (
<div className="flex items-center justify-between">
<FieldLabel>
{label}
{required && <span className="text-destructive ms-0.5">*</span>}
</FieldLabel>
<Button
type="button"
size="icon"
variant="ghost"
className="h-5 w-5"
onClick={() => setIsCreateOpen(true)}
title={`Add new ${label}`}
>
<PlusIcon className="h-3.5 w-3.5" />
</Button>
</div>
)}
<div ref={anchorRef}>
<Combobox
value={field.value}
onValueChange={(val: VendorOption | VendorOption[] | null) => {
const single = Array.isArray(val) ? val[0] ?? null : val
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)
}}
isItemEqualToValue={(item: VendorOption, val: any) =>
item?.value === val?.value
}
>
<ComboboxInput
placeholder={placeholder}
showClear={!!field.value}
onBlur={field.onBlur}
aria-invalid={!!error || undefined}
/>
<ComboboxContent anchor={anchorRef}>
<ComboboxList className="overflow-auto">
{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">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary border border-border text-sm font-medium">
{getInitials(opt)}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-sm font-medium leading-none">
{opt.label}
</span>
<div className="flex items-center gap-2 min-w-0">
{opt.company_name && opt.label !== opt.company_name && (
<span className="flex items-center gap-1 truncate text-xs text-muted-foreground">
<Building2 className="size-3 shrink-0" />
{opt.company_name}
</span>
)}
{opt.phone && (
<span className="text-xs text-muted-foreground truncate">
{opt.phone}
</span>
)}
</div>
</div>
</div>
</ComboboxItem>
))}
{!isLoading && filtered.length === 0 && (
<ComboboxEmpty>No vendors found</ComboboxEmpty>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
{description && <FieldDescription>{description}</FieldDescription>}
{error?.message && <FieldError>{error.message}</FieldError>}
<Dialog open={isCreateOpen} onOpenChange={(v) => { if (!v) setIsCreateOpen(false) }}>
<DialogContent className="min-w-xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">Add Vendor</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[80vh] px-4">
<VendorForm onSuccess={handleCreateSuccess} />
</ScrollArea>
</DialogContent>
</Dialog>
</Field>
)
}