'use client'; import { useMemo, 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'; interface BerthOption { id: string; mooringNumber: string; area: string | null; status: string; } interface BerthPickerProps { value: string | null; onChange: (berthId: string | null) => void; /** When set, the dropdown is scoped to berths linked through any of * this client's interests (via interest_berths.primary). Other berths * are hidden so the picker mirrors the relationship the rep is * already building. */ clientId?: string | null; placeholder?: string; disabled?: boolean; } /** * Searchable berth picker. Free-text search when no client is selected; * scoped to a client's primary-berth set when `clientId` is provided. * * The scoped query fetches the client's interests (limit 25) and * intersects on `berthId`, which mirrors the relationship semantics the * rest of the CRM uses ("berths that show up on this client's deals"). */ export function BerthPicker({ value, onChange, clientId, placeholder = 'Select berth...', disabled, }: BerthPickerProps) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const debounced = useDebounce(search, 300); // Free-text search path — used when there's no clientId scope. const { data: searchData } = useQuery<{ data: BerthOption[] }>({ queryKey: ['berth-picker', 'search', debounced], queryFn: () => { const params = new URLSearchParams({ page: '1', limit: '10', order: 'asc' }); // The list endpoint doesn't accept `search`, so we filter // client-side; pulling a larger page lets the typeahead feel // responsive without round-tripping per keystroke. params.set('limit', '50'); return apiFetch(`/api/v1/berths?${params.toString()}`); }, enabled: open && !clientId, }); // Scoped path — pull this client's interests (with their primary // berth) and dedupe the berth set. const { data: clientInterests } = useQuery<{ data: Array<{ berthId: string | null; berthMooringNumber: string | null }>; }>({ queryKey: ['berth-picker', 'client', clientId], queryFn: () => { const params = new URLSearchParams({ page: '1', limit: '25', order: 'desc', includeArchived: 'false', clientId: clientId!, }); return apiFetch(`/api/v1/interests?${params.toString()}`); }, enabled: open && !!clientId, }); const options: BerthOption[] = useMemo(() => { if (clientId) { const rows = clientInterests?.data ?? []; const seen = new Set(); const out: BerthOption[] = []; for (const r of rows) { if (!r.berthId || seen.has(r.berthId)) continue; seen.add(r.berthId); out.push({ id: r.berthId, mooringNumber: r.berthMooringNumber ?? '', area: null, status: '', }); } if (!debounced) return out; const q = debounced.toLowerCase(); return out.filter((b) => b.mooringNumber.toLowerCase().includes(q)); } const rows = searchData?.data ?? []; if (!debounced) return rows; const q = debounced.toLowerCase(); return rows.filter((b) => b.mooringNumber.toLowerCase().includes(q)); }, [clientId, clientInterests, searchData, debounced]); const labelFor = (o: BerthOption) => o.area ? `Berth ${o.mooringNumber} · ${o.area}` : `Berth ${o.mooringNumber}`; const selectedLabel = (() => { if (!value) return placeholder; const match = options.find((o) => o.id === value); return match ? labelFor(match) : `Berth ${value.slice(0, 8)}`; })(); return ( // `modal` is required when this picker is rendered inside a Sheet / // Dialog — without it the CommandInput stays focus-blocked by the // outer Sheet's focus trap and clicks/typing are silently dropped. {clientId ? 'No berths linked to this client.' : 'No berths found.'} {value ? ( { onChange(null); setOpen(false); }} className="text-muted-foreground" > Clear selection ) : null} {options.map((o) => ( { onChange(o.id); setOpen(false); }} > {labelFor(o)} ))} ); }