253 lines
10 KiB
TypeScript
253 lines
10 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 { CUSTOMER_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 { CustomerForm } from "./customer-form"
|
|
|
|
// ── Customer option type (enriched for display) ──
|
|
|
|
type CustomerOption = {
|
|
value: string
|
|
label: string
|
|
first_name?: string
|
|
last_name?: string
|
|
company_name?: string
|
|
email?: string
|
|
phone?: string
|
|
}
|
|
|
|
function buildCustomerOption(item: any): CustomerOption {
|
|
const name = [item.first_name, item.last_name].filter(Boolean).join(" ")
|
|
const label = name || item.company_name || `Customer #${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: CustomerOption): string {
|
|
if (opt.first_name || opt.last_name) {
|
|
return [opt.first_name?.[0], opt.last_name?.[0]].filter(Boolean).join("").toUpperCase()
|
|
}
|
|
if (opt.company_name) return opt.company_name[0].toUpperCase()
|
|
return "?"
|
|
}
|
|
|
|
// ── Props ──
|
|
|
|
export type RhfCustomerSelectFieldProps<
|
|
TValues extends FieldValues,
|
|
TName extends FieldPath<TValues>,
|
|
> = {
|
|
name: TName
|
|
label?: string
|
|
description?: string
|
|
required?: boolean
|
|
disabled?: boolean
|
|
placeholder?: string
|
|
/** Filter customers by customer_type.name (case-insensitive). */
|
|
customerType?: string
|
|
}
|
|
|
|
// ── Component ──
|
|
|
|
export function RhfCustomerSelectField<
|
|
TValues extends FieldValues,
|
|
TName extends FieldPath<TValues>,
|
|
>({
|
|
name,
|
|
label = "Customer",
|
|
description,
|
|
required,
|
|
disabled,
|
|
placeholder = "Search by name, company, or phone...",
|
|
customerType,
|
|
}: RhfCustomerSelectFieldProps<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<CustomerOption[]>({
|
|
queryKey: [CUSTOMER_ROUTES.INDEX, "customer-select", customerType ?? "all"],
|
|
queryFn: async () => {
|
|
const res = await api.customers.list()
|
|
const items = extractItems(res)
|
|
const filtered = customerType
|
|
? items.filter((item: any) =>
|
|
item.customer_type?.name?.toLowerCase() === customerType.toLowerCase(),
|
|
)
|
|
: items
|
|
return filtered.map(buildCustomerOption)
|
|
},
|
|
staleTime: 5 * 60 * 1000,
|
|
})
|
|
|
|
const filtered = inputValue
|
|
? options.filter((c) =>
|
|
[c.first_name, c.last_name, c.company_name, c.email, c.phone]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase()
|
|
.includes(inputValue.toLowerCase()),
|
|
)
|
|
: options
|
|
|
|
const handleCreateSuccess = (data?: any) => {
|
|
const item = data?.data ?? data
|
|
if (item?.id) {
|
|
field.onChange(buildCustomerOption(item))
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: [CUSTOMER_ROUTES.INDEX, "customer-select", customerType ?? "all"] })
|
|
setIsCreateOpen(false)
|
|
}
|
|
|
|
const combobox = (
|
|
<div ref={anchorRef}>
|
|
<Combobox
|
|
value={field.value}
|
|
onValueChange={(val: CustomerOption | CustomerOption[] | 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: CustomerOption, 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">
|
|
{/* Avatar circle with initials */}
|
|
<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>
|
|
|
|
{/* Identity */}
|
|
<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 customers found</ComboboxEmpty>
|
|
)}
|
|
</ComboboxList>
|
|
</ComboboxContent>
|
|
</Combobox>
|
|
</div>
|
|
)
|
|
|
|
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>
|
|
)}
|
|
{combobox}
|
|
{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 {label}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<ScrollArea className="max-h-[80vh] px-4">
|
|
<CustomerForm onSuccess={handleCreateSuccess} />
|
|
</ScrollArea>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</Field>
|
|
)
|
|
}
|