diff --git a/src/components/expenses/expense-form-dialog.tsx b/src/components/expenses/expense-form-dialog.tsx index 5ba5c88c..1fed60c1 100644 --- a/src/components/expenses/expense-form-dialog.tsx +++ b/src/components/expenses/expense-form-dialog.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { AlertTriangle, Loader2, Upload, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -21,6 +21,7 @@ import { import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet'; import { CurrencyInput } from '@/components/shared/currency-input'; import { CurrencySelect } from '@/components/shared/currency-select'; +import { TripLabelCombobox } from '@/components/expenses/trip-label-combobox'; import { apiFetch } from '@/lib/api/client'; import { createExpenseSchema, type CreateExpenseInput } from '@/lib/validators/expenses'; import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants'; @@ -62,16 +63,6 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi }, }); - // Distinct trip labels for the port — fed into the datalist so the - // form input doubles as an autocomplete. Cached for the dialog's life - // since the list rarely changes mid-session. - const tripLabelsQuery = useQuery<{ data: string[] }>({ - queryKey: ['expenses', 'trip-labels'], - queryFn: () => apiFetch('/api/v1/expenses/trip-labels'), - enabled: open, - staleTime: 60_000, - }); - useEffect(() => { if (open && expense) { reset({ @@ -234,9 +225,7 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi - setValue('currency', v, { shouldDirty: true }) - } + onValueChange={(v) => setValue('currency', v, { shouldDirty: true })} /> {errors.currency && (

{errors.currency.message}

@@ -317,25 +306,14 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
- setValue('tripLabel', label ?? undefined, { shouldDirty: true })} placeholder="e.g. Palm Beach 2026 (optional)" - maxLength={120} - list="expense-trip-label-suggestions" - autoComplete="off" - {...register('tripLabel')} /> - {/* Native datalist gives the rep prior trip values as - * autocomplete suggestions — keeps spellings consistent - * ("Palm Beach 2026" vs "palm-beach 2026") so the PDF - * group-by-trip section actually merges them. */} - - {(tripLabelsQuery.data?.data ?? []).map((label) => ( -

- Group expenses by yacht show or business trip. Leave empty for everyday spend. + Group expenses by yacht show or business trip. Pick a past trip or type a new one.

diff --git a/src/components/expenses/trip-label-combobox.tsx b/src/components/expenses/trip-label-combobox.tsx new file mode 100644 index 00000000..57bcceb2 --- /dev/null +++ b/src/components/expenses/trip-label-combobox.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Check, ChevronsUpDown, Plus } from 'lucide-react'; + +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 { apiFetch } from '@/lib/api/client'; +import { cn } from '@/lib/utils'; + +interface TripLabelComboboxProps { + value: string | null | undefined; + onChange: (label: string | null) => void; + placeholder?: string; + disabled?: boolean; + id?: string; +} + +/** + * Trip label picker. Free-text on first entry, dropdown of past labels + * on subsequent ones. The "Create ''" item lets reps + * commit a brand-new label without leaving the keyboard. Past labels + * come from /api/v1/expenses/trip-labels (distinct values for the + * port, ordered by most-recent expense date). + */ +export function TripLabelCombobox({ + value, + onChange, + placeholder = 'Select or create a trip…', + disabled, + id, +}: TripLabelComboboxProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + + const labelsQuery = useQuery<{ data: string[] }>({ + queryKey: ['expenses', 'trip-labels'], + queryFn: () => apiFetch('/api/v1/expenses/trip-labels'), + enabled: open, + staleTime: 60_000, + }); + + const labels = labelsQuery.data?.data ?? []; + const trimmed = search.trim(); + const showCreate = + trimmed.length > 0 && !labels.some((l) => l.toLowerCase() === trimmed.toLowerCase()); + + const commit = (label: string | null) => { + onChange(label); + setOpen(false); + setSearch(''); + }; + + return ( + + + + + + + + + + {trimmed ? `No matches for "${trimmed}"` : 'No past trips yet'} + + {value ? ( + + commit(null)} + className="text-muted-foreground" + > + Clear selection + + + ) : null} + {showCreate ? ( + + commit(trimmed)}> + + Create “{trimmed}” + + + ) : null} + {labels.length > 0 ? ( + + {labels.map((label) => ( + commit(label)}> + + {label} + + ))} + + ) : null} + + + + + ); +}