diff --git a/src/app/api/v1/files/upload/route.ts b/src/app/api/v1/files/upload/route.ts index 901cf629..cfc1f53b 100644 --- a/src/app/api/v1/files/upload/route.ts +++ b/src/app/api/v1/files/upload/route.ts @@ -17,6 +17,7 @@ export const POST = withAuth( const buffer = Buffer.from(await file.arrayBuffer()); + const folderIdRaw = formData.get('folderId') as string | undefined; const metadata = uploadFileSchema.parse({ filename: (formData.get('filename') as string | null) ?? file.name, clientId: formData.get('clientId') as string | undefined, @@ -25,6 +26,9 @@ export const POST = withAuth( category: formData.get('category') as string | undefined, entityType: formData.get('entityType') 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( diff --git a/src/components/documents/create-document-wizard.tsx b/src/components/documents/create-document-wizard.tsx index 4581907a..e87f116f 100644 --- a/src/components/documents/create-document-wizard.tsx +++ b/src/components/documents/create-document-wizard.tsx @@ -17,10 +17,30 @@ import { SelectValue, } from '@/components/ui/select'; 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 { toastError } from '@/lib/api/toast-error'; 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 = { + 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 SUBJECT_TYPES = [ @@ -216,24 +236,37 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
- - setTemplateId(e.target.value)} - placeholder="Template UUID" + + setTemplateId(id ?? '')} />
) : (
- - setUploadedFileId(e.target.value)} - placeholder="File UUID from /api/v1/files upload" - /> + + {uploadedFileId ? ( +
+ File ready (id: {uploadedFileId.slice(0, 8)}…) + +
+ ) : ( + { + if (file?.id) setUploadedFileId(file.id); + }} + /> + )}

- 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.

)} @@ -274,9 +307,14 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) {
- setSubjectId(e.target.value)} - placeholder={`${subjectType} id`} - /> + {subjectType === 'client' ? ( + setSubjectId(id ?? '')} /> + ) : subjectType === 'company' ? ( + setSubjectId(id ?? '')} + /> + ) : subjectType === 'yacht' ? ( + setSubjectId(id ?? '')} /> + ) : subjectType === 'interest' ? ( + setSubjectId(id ?? '')} + /> + ) : ( + setSubjectId(e.target.value)} + placeholder="Reservation id" + /> + )}
@@ -330,13 +384,13 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) { updateSigner(idx, { signerRole: v as SignerRow['signerRole'] }) } > - + {SIGNER_ROLES.map((r) => ( - {r} + {formatSignerRole(r)} ))} diff --git a/src/components/documents/document-template-picker.tsx b/src/components/documents/document-template-picker.tsx new file mode 100644 index 00000000..6b8b6632 --- /dev/null +++ b/src/components/documents/document-template-picker.tsx @@ -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 ( + + + + + + + + + No templates found. + + {options.map((t) => ( + { + onChange(t.id); + setOpen(false); + }} + > + + + {t.name} + {t.templateType ? ( + + {t.templateType.replace(/_/g, ' ')} + + ) : null} + + + ))} + + + + + + ); +} diff --git a/src/components/files/file-upload-zone.tsx b/src/components/files/file-upload-zone.tsx index 5ae32c68..30c139c9 100644 --- a/src/components/files/file-upload-zone.tsx +++ b/src/components/files/file-upload-zone.tsx @@ -18,7 +18,17 @@ interface FileUploadZoneProps { clientId?: string; yachtId?: 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({ @@ -27,6 +37,7 @@ export function FileUploadZone({ clientId, yachtId, companyId, + folderId, onUploadComplete, }: FileUploadZoneProps) { const [isDragOver, setIsDragOver] = useState(false); @@ -54,6 +65,7 @@ export function FileUploadZone({ if (companyId) formData.append('companyId', companyId); if (entityType) formData.append('entityType', entityType); if (entityId) formData.append('entityId', entityId); + if (folderId) formData.append('folderId', folderId); setUploading((prev) => prev.map((u) => (u.id === uploadId ? { ...u, progress: 50 } : u)), @@ -73,6 +85,16 @@ export function FileUploadZone({ 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) => prev.map((u) => (u.id === uploadId ? { ...u, progress: 100 } : u)), ); @@ -90,7 +112,7 @@ export function FileUploadZone({ onUploadComplete?.(); }, 1500); }, - [clientId, yachtId, companyId, entityType, entityId, onUploadComplete], + [clientId, yachtId, companyId, entityType, entityId, folderId, onUploadComplete], ); const handleDrop = useCallback( diff --git a/src/components/interests/interest-picker.tsx b/src/components/interests/interest-picker.tsx new file mode 100644 index 00000000..97bc446d --- /dev/null +++ b/src/components/interests/interest-picker.tsx @@ -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 ( + + + + + + + + + No interests found. + + {options.map((i) => ( + { + onChange(i.id); + setOpen(false); + }} + > + + + {i.clientName ?? `Interest ${i.id.slice(0, 8)}`} + {i.pipelineStage ? ( + {i.pipelineStage} + ) : null} + + + ))} + + + + + + ); +}