'use client'; import { useState } from 'react'; import { Check, ChevronsUpDown } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { useDebounce } from '@/hooks/use-debounce'; import { apiFetch } from '@/lib/api/client'; import { cn } from '@/lib/utils'; export type OwnerRef = { type: 'client' | 'company'; id: string }; interface OwnerOption { id: string; name?: string | null; fullName?: string | null; } interface OwnerPickerProps { value: OwnerRef | null; onChange: (value: OwnerRef | null) => void; /** Optional placeholder when empty */ placeholder?: string; /** Disable the component */ disabled?: boolean; } export function OwnerPicker({ value, onChange, placeholder = 'Select owner...', disabled, }: OwnerPickerProps) { const [open, setOpen] = useState(false); // `type` is derived: when an owner is selected the prop wins; with no // selection the user's local tab pick is the source of truth. Render- // phase derivation replaces the prior useEffect(setType, [value?.type]) // that the Compiler flagged as set-state-in-effect. const [localType, setLocalType] = useState<'client' | 'company'>(value?.type ?? 'client'); const type: 'client' | 'company' = value?.type ?? localType; const setType = setLocalType; const [search, setSearch] = useState(''); const debounced = useDebounce(search, 300); const endpoint = type === 'client' ? `/api/v1/clients/options?search=${encodeURIComponent(debounced)}` : `/api/v1/companies/autocomplete?q=${encodeURIComponent(debounced)}`; const { data } = useQuery<{ data: OwnerOption[] }>({ queryKey: ['owner-picker', type, debounced], queryFn: () => apiFetch(endpoint), enabled: open, }); const options = data?.data ?? []; // Resolve the current value's display name even before the picker is opened. // Without this primer query the trigger button rendered "Client <8-char-id>" // on first paint and only filled in the real name after the user opened the // dropdown (which kicked the list query). The lookup hits a per-id endpoint // when possible and falls back to scanning the cached options array. const valueLookupEndpoint = value ? value.type === 'client' ? `/api/v1/clients/${value.id}` : `/api/v1/companies/${value.id}` : null; const { data: valueDetail } = useQuery<{ data: { id: string; name?: string | null; fullName?: string | null }; }>({ queryKey: ['owner-picker-resolve', value?.type, value?.id], queryFn: () => apiFetch(valueLookupEndpoint!), enabled: !!value && !!valueLookupEndpoint, staleTime: 60_000, }); // Selected display label - prefer the resolved entity name; fall back to a // truncated id only when both the primer query and the options list miss. const selectedLabel = (() => { if (!value) return placeholder; if (valueDetail?.data) { const name = value.type === 'client' ? valueDetail.data.fullName : valueDetail.data.name; if (name) return name; } const match = options.find((o) => o.id === value.id); if (match) { return type === 'client' ? (match.fullName ?? '(unnamed client)') : (match.name ?? '(unnamed company)'); } return value.type === 'client' ? `Client ${value.id.slice(0, 8)}` : `Company ${value.id.slice(0, 8)}`; })(); return ( {/* Type toggle */}
No results. {options.map((opt) => { const label = type === 'client' ? (opt.fullName ?? '(unnamed)') : (opt.name ?? '(unnamed)'); const isSelected = value?.id === opt.id && value?.type === type; return ( { onChange({ type, id: opt.id }); setOpen(false); }} > {label} ); })}
); }