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:
@@ -17,6 +17,7 @@ export const POST = withAuth(
|
|||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
const folderIdRaw = formData.get('folderId') as string | undefined;
|
||||||
const metadata = uploadFileSchema.parse({
|
const metadata = uploadFileSchema.parse({
|
||||||
filename: (formData.get('filename') as string | null) ?? file.name,
|
filename: (formData.get('filename') as string | null) ?? file.name,
|
||||||
clientId: formData.get('clientId') as string | undefined,
|
clientId: formData.get('clientId') as string | undefined,
|
||||||
@@ -25,6 +26,9 @@ export const POST = withAuth(
|
|||||||
category: formData.get('category') as string | undefined,
|
category: formData.get('category') as string | undefined,
|
||||||
entityType: formData.get('entityType') as string | undefined,
|
entityType: formData.get('entityType') as string | undefined,
|
||||||
entityId: formData.get('entityId') as string | undefined,
|
entityId: formData.get('entityId') as string | undefined,
|
||||||
|
// Hub uploads pass the current folderId so the file lands inside
|
||||||
|
// the user's currently-selected folder. Empty string ⇒ root (null).
|
||||||
|
folderId: folderIdRaw && folderIdRaw.length > 0 ? folderIdRaw : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await uploadFile(
|
const result = await uploadFile(
|
||||||
|
|||||||
@@ -17,10 +17,30 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
|
import { ClientPicker } from '@/components/shared/client-picker';
|
||||||
|
import { CompanyPicker } from '@/components/companies/company-picker';
|
||||||
|
import { YachtPicker } from '@/components/yachts/yacht-picker';
|
||||||
|
import { InterestPicker } from '@/components/interests/interest-picker';
|
||||||
|
import { DocumentTemplatePicker } from '@/components/documents/document-template-picker';
|
||||||
|
import { FileUploadZone } from '@/components/files/file-upload-zone';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
import { DOCUMENT_TYPES } from '@/lib/constants';
|
import { DOCUMENT_TYPES } from '@/lib/constants';
|
||||||
|
|
||||||
|
// Display labels for SIGNER_ROLES — internal values stay lowercase, UI shows
|
||||||
|
// capitalized. Falls back to capitalize-first-letter for any value not in the
|
||||||
|
// explicit map.
|
||||||
|
const SIGNER_ROLE_LABELS: Record<string, string> = {
|
||||||
|
client: 'Client',
|
||||||
|
sales: 'Sales',
|
||||||
|
approver: 'Approver',
|
||||||
|
developer: 'Developer',
|
||||||
|
other: 'Other',
|
||||||
|
};
|
||||||
|
function formatSignerRole(r: string): string {
|
||||||
|
return SIGNER_ROLE_LABELS[r] ?? r.charAt(0).toUpperCase() + r.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
const SIGNER_ROLES = ['client', 'sales', 'approver', 'developer', 'other'] as const;
|
const SIGNER_ROLES = ['client', 'sales', 'approver', 'developer', 'other'] as const;
|
||||||
|
|
||||||
const SUBJECT_TYPES = [
|
const SUBJECT_TYPES = [
|
||||||
@@ -216,24 +236,37 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-xs">Template id</Label>
|
<Label className="text-xs">Template</Label>
|
||||||
<Input
|
<DocumentTemplatePicker
|
||||||
value={templateId}
|
value={templateId || null}
|
||||||
onChange={(e) => setTemplateId(e.target.value)}
|
onChange={(id) => setTemplateId(id ?? '')}
|
||||||
placeholder="Template UUID"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-xs">Uploaded file id</Label>
|
<Label className="text-xs">Upload PDF</Label>
|
||||||
<Input
|
{uploadedFileId ? (
|
||||||
value={uploadedFileId}
|
<div className="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2 text-xs">
|
||||||
onChange={(e) => setUploadedFileId(e.target.value)}
|
<span className="truncate">File ready (id: {uploadedFileId.slice(0, 8)}…)</span>
|
||||||
placeholder="File UUID from /api/v1/files upload"
|
<button
|
||||||
/>
|
type="button"
|
||||||
|
onClick={() => setUploadedFileId('')}
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FileUploadZone
|
||||||
|
onUploadComplete={(file) => {
|
||||||
|
if (file?.id) setUploadedFileId(file.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Upload via the existing file uploader, then paste the returned id here.
|
Drop a PDF or click to browse. The file is stored, then the wizard wires it as
|
||||||
|
the source for signing.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -274,9 +307,14 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
|||||||
<div className="grid grid-cols-[max-content_1fr] gap-2">
|
<div className="grid grid-cols-[max-content_1fr] gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={subjectType}
|
value={subjectType}
|
||||||
onValueChange={(v) => setSubjectType(v as typeof subjectType)}
|
onValueChange={(v) => {
|
||||||
|
setSubjectType(v as typeof subjectType);
|
||||||
|
// Reset subject id when the type changes — pickers are
|
||||||
|
// type-specific and old ids belong to the wrong table.
|
||||||
|
setSubjectId('');
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-32">
|
<SelectTrigger className="h-9 w-32">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -287,11 +325,27 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input
|
{subjectType === 'client' ? (
|
||||||
value={subjectId}
|
<ClientPicker value={subjectId || null} onChange={(id) => setSubjectId(id ?? '')} />
|
||||||
onChange={(e) => setSubjectId(e.target.value)}
|
) : subjectType === 'company' ? (
|
||||||
placeholder={`${subjectType} id`}
|
<CompanyPicker
|
||||||
/>
|
value={subjectId || null}
|
||||||
|
onChange={(id) => setSubjectId(id ?? '')}
|
||||||
|
/>
|
||||||
|
) : subjectType === 'yacht' ? (
|
||||||
|
<YachtPicker value={subjectId || null} onChange={(id) => setSubjectId(id ?? '')} />
|
||||||
|
) : subjectType === 'interest' ? (
|
||||||
|
<InterestPicker
|
||||||
|
value={subjectId || null}
|
||||||
|
onChange={(id) => setSubjectId(id ?? '')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={subjectId}
|
||||||
|
onChange={(e) => setSubjectId(e.target.value)}
|
||||||
|
placeholder="Reservation id"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -330,13 +384,13 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
|
|||||||
updateSigner(idx, { signerRole: v as SignerRow['signerRole'] })
|
updateSigner(idx, { signerRole: v as SignerRow['signerRole'] })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="h-9">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SIGNER_ROLES.map((r) => (
|
{SIGNER_ROLES.map((r) => (
|
||||||
<SelectItem key={r} value={r}>
|
<SelectItem key={r} value={r}>
|
||||||
{r}
|
{formatSignerRole(r)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
119
src/components/documents/document-template-picker.tsx
Normal file
119
src/components/documents/document-template-picker.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
'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 TemplateOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
templateType?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentTemplatePickerProps {
|
||||||
|
value: string | null;
|
||||||
|
onChange: (templateId: string | null) => void;
|
||||||
|
/** Optional filter by templateType (e.g. 'eoi', 'contract'). */
|
||||||
|
templateType?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentTemplatePicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
templateType,
|
||||||
|
placeholder = 'Select template...',
|
||||||
|
disabled,
|
||||||
|
}: DocumentTemplatePickerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const debounced = useDebounce(search, 300);
|
||||||
|
|
||||||
|
const { data } = useQuery<{ data: TemplateOption[] }>({
|
||||||
|
queryKey: ['document-template-picker', debounced, templateType ?? ''],
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
search: debounced,
|
||||||
|
page: '1',
|
||||||
|
limit: '10',
|
||||||
|
order: 'desc',
|
||||||
|
isActive: 'true',
|
||||||
|
});
|
||||||
|
if (templateType) params.set('templateType', templateType);
|
||||||
|
return apiFetch(`/api/v1/document-templates?${params.toString()}`);
|
||||||
|
},
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = data?.data ?? [];
|
||||||
|
|
||||||
|
const selectedLabel = (() => {
|
||||||
|
if (!value) return placeholder;
|
||||||
|
const match = options.find((o) => o.id === value);
|
||||||
|
return match?.name ?? `Template ${value.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 templates…" value={search} onValueChange={setSearch} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No templates found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((t) => (
|
||||||
|
<CommandItem
|
||||||
|
key={t.id}
|
||||||
|
value={t.id}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(t.id);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn('mr-2 h-4 w-4', value === t.id ? 'opacity-100' : 'opacity-0')}
|
||||||
|
/>
|
||||||
|
<span className="truncate">
|
||||||
|
{t.name}
|
||||||
|
{t.templateType ? (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
{t.templateType.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,17 @@ interface FileUploadZoneProps {
|
|||||||
clientId?: string;
|
clientId?: string;
|
||||||
yachtId?: string;
|
yachtId?: string;
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
onUploadComplete?: () => void;
|
/**
|
||||||
|
* Optional folder to deposit the file into. Hub uploads pass the
|
||||||
|
* currently-selected folderId so files land where the user expects.
|
||||||
|
*/
|
||||||
|
folderId?: string | null;
|
||||||
|
/**
|
||||||
|
* Fires per successful upload with the file metadata. The wizard /
|
||||||
|
* inline-upload flows use the returned id to wire follow-up actions
|
||||||
|
* (e.g. set as the source PDF for a Documenso signing flow).
|
||||||
|
*/
|
||||||
|
onUploadComplete?: (file?: { id: string; filename?: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileUploadZone({
|
export function FileUploadZone({
|
||||||
@@ -27,6 +37,7 @@ export function FileUploadZone({
|
|||||||
clientId,
|
clientId,
|
||||||
yachtId,
|
yachtId,
|
||||||
companyId,
|
companyId,
|
||||||
|
folderId,
|
||||||
onUploadComplete,
|
onUploadComplete,
|
||||||
}: FileUploadZoneProps) {
|
}: FileUploadZoneProps) {
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
@@ -54,6 +65,7 @@ export function FileUploadZone({
|
|||||||
if (companyId) formData.append('companyId', companyId);
|
if (companyId) formData.append('companyId', companyId);
|
||||||
if (entityType) formData.append('entityType', entityType);
|
if (entityType) formData.append('entityType', entityType);
|
||||||
if (entityId) formData.append('entityId', entityId);
|
if (entityId) formData.append('entityId', entityId);
|
||||||
|
if (folderId) formData.append('folderId', folderId);
|
||||||
|
|
||||||
setUploading((prev) =>
|
setUploading((prev) =>
|
||||||
prev.map((u) => (u.id === uploadId ? { ...u, progress: 50 } : u)),
|
prev.map((u) => (u.id === uploadId ? { ...u, progress: 50 } : u)),
|
||||||
@@ -73,6 +85,16 @@ export function FileUploadZone({
|
|||||||
throw new Error('Upload failed');
|
throw new Error('Upload failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadJson = (await uploadRes
|
||||||
|
.json()
|
||||||
|
.catch(() => null)) as { data?: { id?: string; filename?: string } } | null;
|
||||||
|
if (uploadJson?.data?.id) {
|
||||||
|
onUploadComplete?.({
|
||||||
|
id: uploadJson.data.id,
|
||||||
|
filename: uploadJson.data.filename,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setUploading((prev) =>
|
setUploading((prev) =>
|
||||||
prev.map((u) => (u.id === uploadId ? { ...u, progress: 100 } : u)),
|
prev.map((u) => (u.id === uploadId ? { ...u, progress: 100 } : u)),
|
||||||
);
|
);
|
||||||
@@ -90,7 +112,7 @@ export function FileUploadZone({
|
|||||||
onUploadComplete?.();
|
onUploadComplete?.();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
},
|
},
|
||||||
[clientId, yachtId, companyId, entityType, entityId, onUploadComplete],
|
[clientId, yachtId, companyId, entityType, entityId, folderId, onUploadComplete],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
const handleDrop = useCallback(
|
||||||
|
|||||||
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