From d4f58abb9ce43d165de462f5ccec8b4977a30852 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 24 Apr 2026 13:32:28 +0200 Subject: [PATCH] feat(ui): add owner-picker and yacht-picker components Task 5.1 of the data-model refactor. Adds: - OwnerPicker: polymorphic combobox that toggles between client and company autocomplete via a type switch inside the popover. Uses /api/v1/clients/options (search=) and /api/v1/companies/autocomplete (q=). - YachtPicker: yacht autocomplete against /api/v1/yachts/autocomplete with optional ownerFilter prop to scope to a given client/company. Both components use TanStack Query with debounced (300ms) input via the existing use-debounce hook, and apiFetch which attaches X-Port-Id. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/shared/owner-picker.tsx | 164 +++++++++++++++++++++++++ src/components/yachts/yacht-picker.tsx | 114 +++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 src/components/shared/owner-picker.tsx create mode 100644 src/components/yachts/yacht-picker.tsx diff --git a/src/components/shared/owner-picker.tsx b/src/components/shared/owner-picker.tsx new file mode 100644 index 0000000..3e8035a --- /dev/null +++ b/src/components/shared/owner-picker.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useEffect, 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); + const [type, setType] = useState<'client' | 'company'>(value?.type ?? 'client'); + const [search, setSearch] = useState(''); + const debounced = useDebounce(search, 300); + + // Keep local `type` in sync if value.type changes externally. + useEffect(() => { + if (value?.type) setType(value.type); + }, [value?.type]); + + 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 ?? []; + + // Selected display label — show entity's name from current options if + // available, otherwise a truncated id fallback. + const selectedLabel = (() => { + if (!value) return placeholder; + 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} + + ); + })} + + + +
+
+ ); +} diff --git a/src/components/yachts/yacht-picker.tsx b/src/components/yachts/yacht-picker.tsx new file mode 100644 index 0000000..9b1a098 --- /dev/null +++ b/src/components/yachts/yacht-picker.tsx @@ -0,0 +1,114 @@ +'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'; + +interface YachtOption { + id: string; + name: string; + hullNumber?: string | null; + registration?: string | null; + currentOwnerType?: 'client' | 'company'; + currentOwnerId?: string; +} + +interface YachtPickerProps { + value: string | null; + onChange: (yachtId: string | null) => void; + /** Optional filter to only show yachts owned by the given client or company. */ + ownerFilter?: { type: 'client' | 'company'; id: string }; + placeholder?: string; + disabled?: boolean; +} + +export function YachtPicker({ + value, + onChange, + ownerFilter, + placeholder = 'Select yacht...', + disabled, +}: YachtPickerProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const debounced = useDebounce(search, 300); + + const { data } = useQuery<{ data: YachtOption[] }>({ + queryKey: ['yacht-picker', debounced], + queryFn: () => apiFetch(`/api/v1/yachts/autocomplete?q=${encodeURIComponent(debounced)}`), + enabled: open, + }); + + const rawOptions = data?.data ?? []; + const options = ownerFilter + ? rawOptions.filter( + (y) => y.currentOwnerType === ownerFilter.type && y.currentOwnerId === ownerFilter.id, + ) + : rawOptions; + + const selectedLabel = (() => { + if (!value) return placeholder; + const match = rawOptions.find((o) => o.id === value); + return match?.name ?? `Yacht ${value.slice(0, 8)}`; + })(); + + return ( + + + + + + + + + No yachts found. + + {options.map((y) => ( + { + onChange(y.id); + setOpen(false); + }} + > + + + {y.name} + {y.hullNumber ? ( + {y.hullNumber} + ) : null} + + + ))} + + + + + + ); +}