feat(documents-wizard): replace UUID-paste fields with searchable pickers + inline upload
Reps no longer have to copy/paste UUIDs into the New-document wizard.
Three UUID inputs replaced:
- Template id Input → DocumentTemplatePicker (queries /api/v1/document-templates
with name search; filters to isActive=true)
- Uploaded file id Input → inline FileUploadZone (drop or browse PDF; surfaces
the uploaded file id directly to the wizard via the new onUploadComplete
signature)
- Subject id Input → conditional picker: ClientPicker / CompanyPicker /
YachtPicker / InterestPicker depending on the subject-type dropdown.
Reservation falls back to Input for now (no ReservationPicker yet).
Other polish in the wizard:
- SIGNER_ROLES labels capitalized in the role select (client → Client, etc.)
via a formatSignerRole() helper. Internal values stay lowercase.
- Pinned h-9 on Select triggers so the type/subject row + signer-role select
vertically align with their adjacent inputs.
- Subject-type change now resets subjectId — picker options are type-specific
and a stale id from a different entity table would be invalid.
Infrastructure for hub uploads (will be consumed in a follow-up dropdown +
drag-drop pass):
- /api/v1/files/upload route now parses folderId from FormData (schema
already supported it).
- FileUploadZone accepts a folderId prop and forwards it, plus a new
onUploadComplete(file) callback shape that surfaces { id, filename } on
each successful upload. Existing per-entity callers (Files tab on clients,
companies, yachts, interests) ignore the arg, no behaviour change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
115
src/components/interests/interest-picker.tsx
Normal file
115
src/components/interests/interest-picker.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'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 InterestOption {
|
||||
id: string;
|
||||
clientId: string;
|
||||
clientName?: string;
|
||||
pipelineStage?: string;
|
||||
// Some list endpoints surface the linked client inline; we display whatever's
|
||||
// available with a fallback to a short id.
|
||||
}
|
||||
|
||||
interface InterestPickerProps {
|
||||
value: string | null;
|
||||
onChange: (interestId: string | null) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function InterestPicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select interest...',
|
||||
disabled,
|
||||
}: InterestPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const debounced = useDebounce(search, 300);
|
||||
|
||||
const { data } = useQuery<{ data: InterestOption[] }>({
|
||||
queryKey: ['interest-picker', debounced],
|
||||
queryFn: () =>
|
||||
apiFetch(
|
||||
`/api/v1/interests?search=${encodeURIComponent(debounced)}&page=1&limit=10&order=desc&includeArchived=false`,
|
||||
),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const options = data?.data ?? [];
|
||||
|
||||
const selectedLabel = (() => {
|
||||
if (!value) return placeholder;
|
||||
const match = options.find((o) => o.id === value);
|
||||
if (!match) return `Interest ${value.slice(0, 8)}`;
|
||||
if (match.clientName) return `${match.clientName} — ${match.pipelineStage ?? 'open'}`;
|
||||
return `Interest ${match.id.slice(0, 8)}`;
|
||||
})();
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={disabled}
|
||||
className={cn('w-full justify-between', !value && 'text-muted-foreground')}
|
||||
>
|
||||
<span className="truncate">{selectedLabel}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Search by client name…"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No interests found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((i) => (
|
||||
<CommandItem
|
||||
key={i.id}
|
||||
value={i.id}
|
||||
onSelect={() => {
|
||||
onChange(i.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn('mr-2 h-4 w-4', value === i.id ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{i.clientName ?? `Interest ${i.id.slice(0, 8)}`}
|
||||
{i.pipelineStage ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">{i.pipelineStage}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user