179 lines
6.5 KiB
TypeScript
179 lines
6.5 KiB
TypeScript
"use client"
|
|
|
|
import { useRef, useState } from "react"
|
|
import { useQuery } from "@tanstack/react-query"
|
|
import { Loader2 } from "lucide-react"
|
|
import { useAuthApi } from "@/shared/useApi"
|
|
import { EMPLOYEE_ROUTES } from "@garage/api"
|
|
import { Badge } from "@/shared/components/ui/badge"
|
|
import {
|
|
Combobox,
|
|
ComboboxInput,
|
|
ComboboxContent,
|
|
ComboboxList,
|
|
ComboboxItem,
|
|
ComboboxEmpty,
|
|
} from "@/shared/components/ui/combobox"
|
|
|
|
// ── Types ──
|
|
|
|
export type EmployeeOption = {
|
|
value: string
|
|
label: string
|
|
first_name?: string
|
|
last_name?: string
|
|
email?: string
|
|
type?: string
|
|
status?: string
|
|
}
|
|
|
|
// ── Helpers ──
|
|
|
|
function buildEmployeeOption(item: any): EmployeeOption {
|
|
const label =
|
|
[item.first_name, item.last_name].filter(Boolean).join(" ") ||
|
|
`Employee #${item.id}`
|
|
return {
|
|
value: String(item.id),
|
|
label,
|
|
first_name: item.first_name,
|
|
last_name: item.last_name,
|
|
email: item.email,
|
|
type: item.type,
|
|
status: item.status,
|
|
}
|
|
}
|
|
|
|
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: EmployeeOption): string {
|
|
return (
|
|
[opt.first_name?.[0], opt.last_name?.[0]].filter(Boolean).join("").toUpperCase() || "E"
|
|
)
|
|
}
|
|
|
|
// ── Props ──
|
|
|
|
type EmployeeComboboxProps = {
|
|
value?: EmployeeOption | null
|
|
onValueChange: (employee: EmployeeOption | null) => void
|
|
disabled?: boolean
|
|
placeholder?: string
|
|
showClear?: boolean
|
|
onBlur?: () => void
|
|
"aria-invalid"?: boolean
|
|
}
|
|
|
|
// ── Component ──
|
|
|
|
export function EmployeeCombobox({
|
|
value,
|
|
onValueChange,
|
|
disabled,
|
|
placeholder = "Search by name or email...",
|
|
showClear,
|
|
onBlur,
|
|
"aria-invalid": ariaInvalid,
|
|
}: EmployeeComboboxProps) {
|
|
const api = useAuthApi()
|
|
const anchorRef = useRef<HTMLDivElement>(null)
|
|
const [inputValue, setInputValue] = useState("")
|
|
|
|
const { data: options = [], isLoading } = useQuery<EmployeeOption[]>({
|
|
queryKey: [EMPLOYEE_ROUTES.INDEX, "employee-select"],
|
|
queryFn: async () => {
|
|
const res = await api.employees.list()
|
|
return extractItems(res).map(buildEmployeeOption)
|
|
},
|
|
staleTime: 5 * 60 * 1000,
|
|
})
|
|
|
|
const filtered = inputValue
|
|
? options.filter((e) =>
|
|
[e.first_name, e.last_name, e.email, e.type]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase()
|
|
.includes(inputValue.toLowerCase()),
|
|
)
|
|
: options
|
|
|
|
return (
|
|
<div ref={anchorRef}>
|
|
<Combobox
|
|
value={value}
|
|
onValueChange={(val: EmployeeOption | EmployeeOption[] | null) => {
|
|
const single = Array.isArray(val) ? val[0] ?? null : val
|
|
onValueChange(single)
|
|
}}
|
|
disabled={disabled}
|
|
onInputValueChange={(val: string, { reason }: { reason: string }) => {
|
|
if (reason === "input-change") setInputValue(val)
|
|
}}
|
|
isItemEqualToValue={(item: EmployeeOption, val: any) =>
|
|
item?.value === val?.value
|
|
}
|
|
>
|
|
<ComboboxInput
|
|
placeholder={placeholder}
|
|
showClear={showClear ?? !!value}
|
|
onBlur={onBlur}
|
|
aria-invalid={ariaInvalid || 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">
|
|
<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-1.5">
|
|
{opt.type && (
|
|
<Badge
|
|
variant="secondary"
|
|
className="h-4 px-1.5 text-xs font-normal capitalize"
|
|
>
|
|
{opt.type}
|
|
</Badge>
|
|
)}
|
|
{opt.status && (
|
|
<span
|
|
className={`text-xs capitalize ${opt.status === "active" ? "text-green-600 dark:text-green-400" : "text-muted-foreground"}`}
|
|
>
|
|
{opt.status}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ComboboxItem>
|
|
))}
|
|
|
|
{!isLoading && filtered.length === 0 && (
|
|
<ComboboxEmpty>No employees found</ComboboxEmpty>
|
|
)}
|
|
</ComboboxList>
|
|
</ComboboxContent>
|
|
</Combobox>
|
|
</div>
|
|
)
|
|
}
|