feat(expenses): combobox trip-label picker (free text + past suggestions)

Replaces the HTML5 datalist Input with a Popover + cmdk Combobox
modeled after CountryCombobox. Free-text on first entry via the
"Create '<typed value>'" item; past labels grouped under "Past trips"
with a check-mark indicating the current selection. Reuses the
existing /api/v1/expenses/trip-labels endpoint (distinct values for
the port, ordered by most-recent expense date) — no new schema or
service work.

Drops useQuery from expense-form-dialog since the combobox now owns
its own data fetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 18:36:16 +02:00
parent 20ee2c1dcf
commit 7c25d1aef6
2 changed files with 134 additions and 29 deletions

View File

@@ -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
<CurrencySelect
id="currency"
value={watch('currency') ?? 'USD'}
onValueChange={(v) =>
setValue('currency', v, { shouldDirty: true })
}
onValueChange={(v) => setValue('currency', v, { shouldDirty: true })}
/>
{errors.currency && (
<p className="text-xs text-destructive">{errors.currency.message}</p>
@@ -317,25 +306,14 @@ export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDi
<div className="space-y-2">
<Label htmlFor="tripLabel">Trip / event</Label>
<Input
<TripLabelCombobox
id="tripLabel"
value={watch('tripLabel') ?? ''}
onChange={(label) => 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. */}
<datalist id="expense-trip-label-suggestions">
{(tripLabelsQuery.data?.data ?? []).map((label) => (
<option key={label} value={label} />
))}
</datalist>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>

View File

@@ -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 '<typed value>'" 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={id}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn('w-full justify-between font-normal', !value && 'text-muted-foreground')}
>
<span className="truncate">{value || placeholder}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popper-anchor-width)] min-w-[260px] p-0">
<Command shouldFilter={true}>
<CommandInput
placeholder="Type a trip name…"
value={search}
onValueChange={setSearch}
maxLength={120}
/>
<CommandList>
<CommandEmpty>
{trimmed ? `No matches for "${trimmed}"` : 'No past trips yet'}
</CommandEmpty>
{value ? (
<CommandGroup>
<CommandItem
value="__clear__"
onSelect={() => commit(null)}
className="text-muted-foreground"
>
Clear selection
</CommandItem>
</CommandGroup>
) : null}
{showCreate ? (
<CommandGroup heading="New">
<CommandItem value={`__create__${trimmed}`} onSelect={() => commit(trimmed)}>
<Plus className="mr-2 h-4 w-4" />
<span className="truncate">Create &ldquo;{trimmed}&rdquo;</span>
</CommandItem>
</CommandGroup>
) : null}
{labels.length > 0 ? (
<CommandGroup heading="Past trips">
{labels.map((label) => (
<CommandItem key={label} value={label} onSelect={() => commit(label)}>
<Check
className={cn('mr-2 h-4 w-4', value === label ? 'opacity-100' : 'opacity-0')}
/>
<span className="truncate">{label}</span>
</CommandItem>
))}
</CommandGroup>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}